From a0f96718083e3ebc7e4446b9ebac8bc360731ea5 Mon Sep 17 00:00:00 2001 From: Arjay Bayona Date: Sun, 13 Sep 2026 13:49:27 +0800 Subject: [PATCH 01/12] style(collaboration): update application theme --- examples/collaboration/app/globals.css | 199 +++++++++++++++++-------- 1 file changed, 134 insertions(+), 65 deletions(-) diff --git a/examples/collaboration/app/globals.css b/examples/collaboration/app/globals.css index 6158571..3b39983 100644 --- a/examples/collaboration/app/globals.css +++ b/examples/collaboration/app/globals.css @@ -12,7 +12,7 @@ @theme inline { --font-heading: var(--font-sans); - --font-sans: "Inter", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --font-sans: DM Sans, ui-sans-serif, sans-serif, system-ui; --color-sidebar-ring: var(--sidebar-ring); --color-sidebar-border: var(--sidebar-border); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); @@ -51,75 +51,143 @@ --radius-2xl: calc(var(--radius) * 1.8); --radius-3xl: calc(var(--radius) * 2.2); --radius-4xl: calc(var(--radius) * 2.6); + --font-mono: DM Mono, ui-monospace, monospace; + --font-serif: DM Serif Text, ui-serif, serif; + --radius: 0.375rem; + --tracking-tighter: calc(var(--tracking-normal) - 0.05em); + --tracking-tight: calc(var(--tracking-normal) - 0.025em); + --tracking-wide: calc(var(--tracking-normal) + 0.025em); + --tracking-wider: calc(var(--tracking-normal) + 0.05em); + --tracking-widest: calc(var(--tracking-normal) + 0.1em); + --tracking-normal: var(--tracking-normal); + --shadow-2xl: var(--shadow-2xl); + --shadow-xl: var(--shadow-xl); + --shadow-lg: var(--shadow-lg); + --shadow-md: var(--shadow-md); + --shadow: var(--shadow); + --shadow-sm: var(--shadow-sm); + --shadow-xs: var(--shadow-xs); + --shadow-2xs: var(--shadow-2xs); + --spacing: var(--spacing); + --letter-spacing: var(--letter-spacing); + --shadow-offset-y: var(--shadow-offset-y); + --shadow-offset-x: var(--shadow-offset-x); + --shadow-spread: var(--shadow-spread); + --shadow-blur: var(--shadow-blur); + --shadow-opacity: var(--shadow-opacity); + --color-shadow-color: var(--shadow-color); + --color-destructive-foreground: var(--destructive-foreground); } :root { - --background: oklch(0.984 0.002 260); - --foreground: oklch(0.145 0 0); - --card: oklch(1 0 0); - --card-foreground: oklch(0.145 0 0); - --popover: oklch(1 0 0); - --popover-foreground: oklch(0.145 0 0); - --primary: oklch(0.52 0.19 266); - --primary-foreground: oklch(0.985 0 0); - --secondary: oklch(0.97 0 0); - --secondary-foreground: oklch(0.205 0 0); - --muted: oklch(0.97 0 0); - --muted-foreground: oklch(0.556 0 0); - --accent: oklch(0.97 0 0); - --accent-foreground: oklch(0.205 0 0); - --destructive: oklch(0.577 0.245 27.325); - --border: oklch(0.922 0 0); - --input: oklch(0.922 0 0); - --ring: oklch(0.708 0 0); - --chart-1: oklch(0.87 0 0); - --chart-2: oklch(0.556 0 0); - --chart-3: oklch(0.439 0 0); - --chart-4: oklch(0.371 0 0); - --chart-5: oklch(0.269 0 0); - --radius: 0.625rem; - --sidebar: oklch(0.975 0.003 260); - --sidebar-foreground: oklch(0.145 0 0); - --sidebar-primary: oklch(0.205 0 0); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.97 0 0); - --sidebar-accent-foreground: oklch(0.205 0 0); - --sidebar-border: oklch(0.922 0 0); - --sidebar-ring: oklch(0.708 0 0); + --background: oklch(0.9789 0.0013 106.4235); + --foreground: oklch(0.2300 0.0038 106.6929); + --card: oklch(1.0000 0 0); + --card-foreground: oklch(0.2300 0.0038 106.6929); + --popover: oklch(1.0000 0 0); + --popover-foreground: oklch(0.2300 0.0038 106.6929); + --primary: oklch(0.2931 0 0); + --primary-foreground: oklch(0.9789 0.0013 106.4235); + --secondary: oklch(0.9688 0.0066 106.5223); + --secondary-foreground: oklch(0.3583 0.0084 106.8176); + --muted: oklch(0.9651 0.0074 80.7210); + --muted-foreground: oklch(0.5363 0.0092 106.7047); + --accent: oklch(0.8778 0.1383 86.1920); + --accent-foreground: oklch(0.2931 0 0); + --destructive: oklch(0.5904 0.2000 26.1033); + --border: oklch(0.9193 0.0099 87.4740); + --input: oklch(0.9193 0.0099 87.4740); + --ring: oklch(0.8778 0.1383 86.1920); + --chart-1: oklch(0.8778 0.1383 86.1920); + --chart-2: oklch(0.6410 0.0117 106.7247); + --chart-3: oklch(0.7509 0.0853 67.0880); + --chart-4: oklch(0.4693 0.0095 106.7590); + --chart-5: oklch(0.7539 0.1519 80.8048); + --radius: 0.375rem; + --sidebar: oklch(0.9041 0.0058 84.5675); + --sidebar-foreground: oklch(0.2300 0.0038 106.6929); + --sidebar-primary: oklch(0.2300 0.0038 106.6929); + --sidebar-primary-foreground: oklch(0.9789 0.0013 106.4235); + --sidebar-accent: oklch(0.9193 0.0099 87.4740); + --sidebar-accent-foreground: oklch(0.2300 0.0038 106.6929); + --sidebar-border: oklch(0.9193 0.0099 87.4740); + --sidebar-ring: oklch(0.8778 0.1383 86.1920); + --destructive-foreground: oklch(1.0000 0 0); + --font-sans: DM Sans, ui-sans-serif, sans-serif, system-ui; + --font-serif: DM Serif Text, ui-serif, serif; + --font-mono: DM Mono, ui-monospace, monospace; + --shadow-color: #17171c; + --shadow-opacity: 0.1; + --shadow-blur: 0.5rem; + --shadow-spread: 0rem; + --shadow-offset-x: 0rem; + --shadow-offset-y: 0.25rem; + --letter-spacing: -0.025em; + --spacing: 0.25rem; + --shadow-2xs: 0rem 0.25rem 0.5rem 0rem hsl(240 9.8039% 10% / 0.05); + --shadow-xs: 0rem 0.25rem 0.5rem 0rem hsl(240 9.8039% 10% / 0.05); + --shadow-sm: 0rem 0.25rem 0.5rem 0rem hsl(240 9.8039% 10% / 0.10), 0rem 1px 2px -1px hsl(240 9.8039% 10% / 0.10); + --shadow: 0rem 0.25rem 0.5rem 0rem hsl(240 9.8039% 10% / 0.10), 0rem 1px 2px -1px hsl(240 9.8039% 10% / 0.10); + --shadow-md: 0rem 0.25rem 0.5rem 0rem hsl(240 9.8039% 10% / 0.10), 0rem 2px 4px -1px hsl(240 9.8039% 10% / 0.10); + --shadow-lg: 0rem 0.25rem 0.5rem 0rem hsl(240 9.8039% 10% / 0.10), 0rem 4px 6px -1px hsl(240 9.8039% 10% / 0.10); + --shadow-xl: 0rem 0.25rem 0.5rem 0rem hsl(240 9.8039% 10% / 0.10), 0rem 8px 10px -1px hsl(240 9.8039% 10% / 0.10); + --shadow-2xl: 0rem 0.25rem 0.5rem 0rem hsl(240 9.8039% 10% / 0.25); + --tracking-normal: -0.025em; } .dark { - --background: oklch(0.145 0 0); - --foreground: oklch(0.985 0 0); - --card: oklch(0.205 0 0); - --card-foreground: oklch(0.985 0 0); - --popover: oklch(0.205 0 0); - --popover-foreground: oklch(0.985 0 0); - --primary: oklch(0.922 0 0); - --primary-foreground: oklch(0.205 0 0); - --secondary: oklch(0.269 0 0); - --secondary-foreground: oklch(0.985 0 0); - --muted: oklch(0.269 0 0); - --muted-foreground: oklch(0.708 0 0); - --accent: oklch(0.269 0 0); - --accent-foreground: oklch(0.985 0 0); - --destructive: oklch(0.704 0.191 22.216); - --border: oklch(1 0 0 / 10%); - --input: oklch(1 0 0 / 15%); - --ring: oklch(0.556 0 0); - --chart-1: oklch(0.87 0 0); - --chart-2: oklch(0.556 0 0); - --chart-3: oklch(0.439 0 0); - --chart-4: oklch(0.371 0 0); - --chart-5: oklch(0.269 0 0); - --sidebar: oklch(0.205 0 0); - --sidebar-foreground: oklch(0.985 0 0); - --sidebar-primary: oklch(0.488 0.243 264.376); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.269 0 0); - --sidebar-accent-foreground: oklch(0.985 0 0); - --sidebar-border: oklch(1 0 0 / 10%); - --sidebar-ring: oklch(0.556 0 0); + --background: oklch(0.2174 0.0019 106.5582); + --foreground: oklch(0.9486 0.0027 106.4500); + --card: oklch(0.2213 0.0038 106.7070); + --card-foreground: oklch(0.9486 0.0027 106.4500); + --popover: oklch(0.2213 0.0038 106.7070); + --popover-foreground: oklch(0.9486 0.0027 106.4500); + --primary: oklch(0.9486 0.0027 106.4500); + --primary-foreground: oklch(0.2174 0.0019 106.5582); + --secondary: oklch(0.2720 0.0036 106.6382); + --secondary-foreground: oklch(0.7903 0.0083 106.5888); + --muted: oklch(0.2720 0.0036 106.6382); + --muted-foreground: oklch(0.6410 0.0117 106.7247); + --accent: oklch(0.7007 0.1304 84.6350); + --accent-foreground: oklch(1.0000 0 0); + --destructive: oklch(0.6091 0.1931 24.9679); + --border: oklch(0.3001 0.0053 106.7146); + --input: oklch(0.3001 0.0053 106.7146); + --ring: oklch(0.7007 0.1304 84.6350); + --chart-1: oklch(0.7007 0.1304 84.6350); + --chart-2: oklch(0.6410 0.0117 106.7247); + --chart-3: oklch(0.7509 0.0853 67.0880); + --chart-4: oklch(0.7903 0.0083 106.5888); + --chart-5: oklch(0.7902 0.1311 87.6550); + --sidebar: oklch(0.2174 0.0019 106.5582); + --sidebar-foreground: oklch(0.9486 0.0027 106.4500); + --sidebar-primary: oklch(0.9486 0.0027 106.4500); + --sidebar-primary-foreground: oklch(0.2174 0.0019 106.5582); + --sidebar-accent: oklch(0.2720 0.0036 106.6382); + --sidebar-accent-foreground: oklch(0.9486 0.0027 106.4500); + --sidebar-border: oklch(0.3001 0.0053 106.7146); + --sidebar-ring: oklch(0.7007 0.1304 84.6350); + --destructive-foreground: oklch(1.0000 0 0); + --radius: 0.375rem; + --font-sans: DM Sans, ui-sans-serif, sans-serif, system-ui; + --font-serif: DM Serif Text, ui-serif, serif; + --font-mono: DM Mono, ui-monospace, monospace; + --shadow-color: #14181f; + --shadow-opacity: 0.6; + --shadow-blur: 0.6rem; + --shadow-spread: 0rem; + --shadow-offset-x: 0rem; + --shadow-offset-y: 0.3rem; + --letter-spacing: -0.025em; + --spacing: 0.25rem; + --shadow-2xs: 0rem 0.3rem 0.6rem 0rem hsl(218.1818 21.5686% 10% / 0.30); + --shadow-xs: 0rem 0.3rem 0.6rem 0rem hsl(218.1818 21.5686% 10% / 0.30); + --shadow-sm: 0rem 0.3rem 0.6rem 0rem hsl(218.1818 21.5686% 10% / 0.60), 0rem 1px 2px -1px hsl(218.1818 21.5686% 10% / 0.60); + --shadow: 0rem 0.3rem 0.6rem 0rem hsl(218.1818 21.5686% 10% / 0.60), 0rem 1px 2px -1px hsl(218.1818 21.5686% 10% / 0.60); + --shadow-md: 0rem 0.3rem 0.6rem 0rem hsl(218.1818 21.5686% 10% / 0.60), 0rem 2px 4px -1px hsl(218.1818 21.5686% 10% / 0.60); + --shadow-lg: 0rem 0.3rem 0.6rem 0rem hsl(218.1818 21.5686% 10% / 0.60), 0rem 4px 6px -1px hsl(218.1818 21.5686% 10% / 0.60); + --shadow-xl: 0rem 0.3rem 0.6rem 0rem hsl(218.1818 21.5686% 10% / 0.60), 0rem 8px 10px -1px hsl(218.1818 21.5686% 10% / 0.60); + --shadow-2xl: 0rem 0.3rem 0.6rem 0rem hsl(218.1818 21.5686% 10% / 1.50); } @layer base { @@ -128,6 +196,7 @@ } body { @apply bg-background text-foreground; + letter-spacing: var(--tracking-normal); } html { @apply font-sans; @@ -241,4 +310,4 @@ .surface .cm-placeholder { position: absolute; inset-inline: 0; top: 0; pointer-events: none; } .rich-document ul { list-style-type: disc; } .rich-document ol { list-style-type: decimal; } -.rich-document h1, .rich-document h2, .rich-document h3 { font-weight: 600; } +.rich-document h1, .rich-document h2, .rich-document h3 { font-weight: 600; } \ No newline at end of file From b3e5a0559c9c097b32d4684f7f26e7779ba0002c Mon Sep 17 00:00:00 2001 From: Arjay Bayona Date: Sun, 13 Sep 2026 18:57:52 +0800 Subject: [PATCH 02/12] feat(examples): revamp collaboration app as a playground --- .gitignore | 2 + README.md | 1 + examples/collaboration/README.md | 12 +- examples/collaboration/app/globals.css | 135 ++-- examples/collaboration/app/layout.tsx | 4 +- .../collaboration/components/example-app.tsx | 517 +++++-------- .../components/playground-header.tsx | 125 +++ .../collaboration/components/room-dialog.tsx | 191 +++++ .../components/ui/alert-dialog.tsx | 199 +++++ .../collaboration/components/ui/dialog.tsx | 167 ++++ .../components/ui/dropdown-menu.tsx | 272 +++++++ examples/collaboration/components/ui/tabs.tsx | 89 +++ .../components/workspace/workspace.tsx | 718 ++++++++++++------ examples/collaboration/flowchart/canvas.tsx | 1 - examples/collaboration/kanban/board.ts | 1 - .../collaboration/multiplayer-form/form.ts | 1 - examples/collaboration/next.config.ts | 1 + examples/collaboration/playwright.config.ts | 6 +- examples/collaboration/rich-text/editor.ts | 1 - examples/collaboration/table/table.ts | 1 - .../collaboration/tests/collaboration.spec.ts | 4 +- .../collaboration/tests/flowchart.spec.ts | 3 +- examples/collaboration/tests/kanban.spec.ts | 6 +- .../collaboration/tests/permissions.spec.ts | 13 +- .../collaboration/tests/playground.spec.ts | 168 ++++ .../collaboration/tests/rich-text.spec.ts | 3 +- examples/collaboration/tests/table.spec.ts | 3 +- examples/collaboration/tsconfig.json | 4 +- lib/synixir/room_access.ex | 31 +- .../controllers/room_controller.ex | 1 + lib/synixir_web/router.ex | 1 + test/synixir/room_permissions_test.exs | 21 + .../controllers/session_controller_test.exs | 1 + 33 files changed, 2058 insertions(+), 645 deletions(-) create mode 100644 examples/collaboration/components/playground-header.tsx create mode 100644 examples/collaboration/components/room-dialog.tsx create mode 100644 examples/collaboration/components/ui/alert-dialog.tsx create mode 100644 examples/collaboration/components/ui/dialog.tsx create mode 100644 examples/collaboration/components/ui/dropdown-menu.tsx create mode 100644 examples/collaboration/components/ui/tabs.tsx create mode 100644 examples/collaboration/tests/playground.spec.ts diff --git a/.gitignore b/.gitignore index 4168856..b295956 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,8 @@ __pycache__/ # TypeScript and Next.js build output. /packages/client/dist/ /examples/collaboration/.next/ +/examples/collaboration/.next-browser-test/ +/examples/collaboration/.next-build-check/ /examples/collaboration/out/ *.tsbuildinfo /examples/collaboration/next-env.d.ts diff --git a/README.md b/README.md index ce785c7..9f8a305 100644 --- a/README.md +++ b/README.md @@ -509,6 +509,7 @@ bearer grant. | `DELETE /api/session` | Revoke the current session and sign out | | `GET /api/rooms` | List the account's active memberships | | `POST /api/rooms` | Create `room_id` and its initial owner atomically | +| `GET /api/rooms/:room_id` | Room info and member usernames/roles for current room members | | `POST /api/rooms/:room_id/token` | Issue a grant using current session and membership | | `GET /api/rooms/:room_id/members` | Owner-only membership list | | `PUT /api/rooms/:room_id/members/:username` | Owner grants or changes `role` | diff --git a/examples/collaboration/README.md b/examples/collaboration/README.md index 49a90f3..788e2c8 100644 --- a/examples/collaboration/README.md +++ b/examples/collaboration/README.md @@ -1,4 +1,4 @@ -# Next.js collaboration examples +# Synixir playground This app uses TypeScript, React, Next.js App Router, shadcn/ui (Radix), and Tailwind CSS. Elixir/Phoenix owns accounts, room permissions, WebSockets, and @@ -24,6 +24,14 @@ listeners and bindings when that host unmounts. They load only when selected. `components/ui/` contains the generated shadcn primitives, and `app/globals.css` contains Tailwind, theme tokens, and shared layout styles. +The playground gives the selected example the full workspace. In the header, +the example dropdown keeps the current room when switching examples. Click the +room ID for room information, the full member roster with live online status, +and a second tab for creating a room. The avatar group opens access management; +only owners can grant, change, or remove access. The username menu contains +theme switching and sign out. Connection details use a shadcn alert dialog in +the footer, alongside save status and help. + ## Libraries by example | Example | UI and editing packages | Shared data | @@ -58,6 +66,8 @@ preserved in development and production. Navigation uses full page loads to retain the unsaved-edit warning and tear down old room connections. Browser tests run on ports 5174 and 4010 with a separate Phoenix test database. +They use `.next-browser-test` so an existing development server can stay open. +Set `SYNIXIR_NEXT_DIST_DIR` to use another Next.js build directory. When testing separate Git worktrees in parallel, set `SYNIXIR_TEST_FRONTEND_PORT` and `SYNIXIR_TEST_BACKEND_PORT` to a unique pair for each run. The test server's origin allowlist follows the selected frontend port. diff --git a/examples/collaboration/app/globals.css b/examples/collaboration/app/globals.css index 3b39983..201a41f 100644 --- a/examples/collaboration/app/globals.css +++ b/examples/collaboration/app/globals.css @@ -206,67 +206,50 @@ button:not(:disabled), [role="button"]:not(:disabled) { cursor: pointer; } [hidden] { display: none !important; } } -.app-frame { display: grid; grid-template-columns: 224px minmax(0,1fr); min-height: 100vh; } -.app-sidebar { position: sticky; top: 0; height: 100vh; display: flex; flex-direction: column; padding: 30px 18px 20px; background: var(--sidebar); border-right: 1px solid var(--border); } -.brand { display: flex; gap: 10px; align-items: center; font-size: 19px; font-weight: 650; letter-spacing: -.6px; margin: 0 8px 36px; } -.brand-mark { display: grid; place-items: center; height: 34px; width: 34px; border-radius: 10px; background: var(--primary); color: var(--primary-foreground); } -.brand-caption { display: block; font-size: 9px; font-weight: 400; letter-spacing: 0; color: var(--muted-foreground); margin-top: 3px; } -.sidebar-section { margin: 0 12px 10px; font-size: 10px; text-transform: uppercase; letter-spacing: 1.4px; font-weight: 600; color: var(--muted-foreground); } -.sidebar-home, .nav-item { display: flex; align-items: center; gap: 11px; padding: 10px 12px; font-size: 12px; border-radius: 7px; color: var(--muted-foreground); transition: background .15s; } -.sidebar-home:hover, .nav-item:hover { background: var(--accent); color: var(--foreground); } -.example-navigation { display: flex; flex-direction: column; gap: 4px; } -.nav-item-active { background: var(--card); color: var(--primary); box-shadow: 0 1px 3px #00000009; font-weight: 600; } -.nav-dot { height: 5px; width: 5px; border-radius: 50%; background: var(--primary); margin-left: auto; } -.sidebar-bottom { margin-top: auto; display: flex; flex-direction: column; gap: 18px; padding: 20px 6px 0; } -.app-main { display: flex; flex-direction: column; min-width: 0; } -.app-topbar { min-height: 76px; padding: 0 36px; border-bottom: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between; background: var(--card); gap: 12px; } -.main-content { width: 100%; max-width: 1600px; margin: 0 auto; padding: 36px; flex: 1; } -.page-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 30px; } -.eyebrow { color: var(--primary); letter-spacing: 2px; font-size: 9px; font-weight: 650; margin-bottom: 10px; } -.page-heading h1 { font-size: clamp(26px, 2.6vw, 34px); line-height: 1.2; font-weight: 620; letter-spacing: -1.1px; } -.page-description { margin-top: 9px; font-size: 13px; color: var(--muted-foreground); } -.presence-dot { width: 5px; height: 5px; border-radius: 50%; background: var(--primary); } -.welcome-grid { display: grid; grid-template-columns: minmax(320px, 440px) minmax(240px,1fr); gap: 70px; align-items: center; padding: 24px 0 48px; } -.welcome-note { max-width: 340px; justify-self: center; text-align: center; } -.welcome-note h2 { font-size: 20px; font-weight: 550; letter-spacing: -.6px; margin: 24px 0 10px; } -.welcome-note p { font-size: 13px; line-height: 1.7; color: var(--muted-foreground); } -.welcome-art { position: relative; height: 170px; width: 200px; margin: 0 auto; } -.art-card { position: absolute; top: 8px; left: 30px; height: 156px; width: 150px; padding: 22px; border: 1px solid var(--border); border-radius: 13px; background: var(--card); transform: rotate(7deg); color: var(--primary); box-shadow: 0 12px 28px #00000006; } -.art-card-back { transform: rotate(-12deg); left: 4px; top: 0; background: var(--muted); } -.art-line { margin-top: 16px; height: 5px; border-radius: 10px; background: var(--border); } -.art-line.short { width: 64%; margin-top: 8px; } -.art-avatars { display: flex; margin-top: 16px; } -.art-avatars span { border: 2px solid var(--card); border-radius: 50%; background: var(--secondary); width: 25px; height: 25px; font-size: 8px; display: grid; place-items: center; margin-right: -6px; } -.rooms-grid { display: grid; grid-template-columns: minmax(0,1.3fr) minmax(270px,1fr); gap: 20px; } -.room-list { display: flex; flex-direction: column; gap: 5px; } -.room-list a { display: flex; gap: 12px; align-items: center; border: 1px solid var(--border); border-radius: 8px; padding: 13px; font-size: 13px; } -.room-list a:hover { background: var(--accent); } -.room-icon { color: var(--primary); background: var(--secondary); border-radius: 7px; padding: 8px; } -.section-heading { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; } -.section-heading h2 { font-size: 15px; font-weight: 550; } -.section-heading>span { font-size: 11px; color: var(--muted-foreground); } -.examples-grid { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: 12px; } -.example-card { position: relative; border: 1px solid var(--border); background: var(--card); border-radius: 9px; padding: 22px; } -.example-card>svg:first-child { color: var(--primary); margin-bottom: 20px; } -.example-card h3 { font-size: 13px; font-weight: 550; margin-bottom: 6px; } -.example-card p { font-size: 11px; line-height: 1.6; color: var(--muted-foreground); max-width: 180px; } -.example-card:hover { border-color: var(--ring); } -.example-arrow { position: absolute; top: 22px; right: 20px; color: var(--muted-foreground); } -.workspace-status { display: flex; gap: 16px; justify-content: space-between; align-items: center; } -#status { font-size: 12px; color: var(--muted-foreground); } -#status[data-state="connected"] { color: var(--primary); } +.playground-shell { display: flex; flex-direction: column; height: calc(100dvh - 40px); min-height: 480px; margin: 20px; padding: 0 28px; border: 1px solid var(--border); border-radius: 16px; background: var(--background); } +.playground-header { display: flex; align-items: center; justify-content: space-between; gap: 24px; min-height: 88px; padding: 16px 0; } +.playground-location { display: flex; align-items: center; min-width: 0; padding: 5px; border: 1px solid var(--border); border-radius: 10px; background: var(--card); } +.location-divider { margin: 0 3px; color: var(--muted-foreground); font-size: 18px; font-weight: 300; } +.room-trigger { flex-shrink: 1; min-width: 0; max-width: min(32vw, 360px); } +.signed-out-room { min-width: 0; padding: 0 10px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; color: var(--muted-foreground); } +.playground-people { display: flex; align-items: center; gap: 24px; min-width: 0; } +#account-bar { min-width: 0; max-width: 240px; } +.members-trigger { gap: 10px; } +.avatar-stack { display: flex; align-items: center; } +.avatar-stack [data-slot="avatar"] { border: 2px solid var(--background); } +.avatar-stack [data-slot="avatar"] + [data-slot="avatar"] { margin-left: -10px; } +.playground-content { min-height: 0; min-width: 0; flex: 1; overflow: auto; border: 1px solid var(--border); border-radius: 12px; background: var(--card); } +.playground-content > [role="alert"]:not(:empty) { margin: 16px; width: auto; } +.playground-lobby { display: grid; place-items: center; padding: 36px 24px; } +.lobby-content { width: 100%; max-width: 460px; display: flex; flex-direction: column; gap: 24px; } +.lobby-heading { display: flex; flex-direction: column; gap: 8px; } +.eyebrow { color: var(--muted-foreground); letter-spacing: 2px; font-size: 10px; font-weight: 600; } +.lobby-heading h1 { font-size: 28px; font-weight: 600; letter-spacing: -1px; } +.lobby-heading > p:last-child { font-size: 14px; color: var(--muted-foreground); } +.room-list { display: flex; flex-direction: column; gap: 8px; } +.room-list a { display: flex; align-items: center; gap: 12px; padding: 14px; border: 1px solid var(--border); border-radius: 8px; font-size: 13px; } +.room-list a:hover { background: var(--muted); } +.playground-dialog { max-height: calc(100dvh - 48px); overflow-y: auto; } +.member-presence { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--muted-foreground); } +.status-dot { display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: var(--muted-foreground); } +.member-presence[data-online="true"] .status-dot { background: var(--primary); } +.playground-footer { display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); align-items: center; gap: 16px; min-height: 56px; color: var(--muted-foreground); font-size: 12px; } +.footer-connection { display: flex; align-items: center; gap: 6px; min-width: 0; } +.footer-connection > button:first-child { min-width: 0; flex-shrink: 1; } +#status { max-width: 220px; } #status[data-state="error"] { color: var(--destructive); } -.workspace-grid { display: grid; grid-template-columns: minmax(0,1fr) 260px; gap: 22px; align-items: start; } -.room-sidebar { display: flex; flex-direction: column; gap: 16px; } -.document-panel { border: 1px solid var(--border); border-radius: 12px; background: var(--card); overflow: hidden; } -.editor-toolbar { padding: 10px 16px; border-bottom: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between; gap: 12px; } -.document-title { font-size: 12px; font-weight: 550; overflow-wrap: anywhere; } -.editor-footer { display: flex; align-items: center; justify-content: space-between; padding: 12px 18px; border-top: 1px solid var(--border); font-size: 10px; color: var(--muted-foreground); } -.editor-footer-dot { height: 5px; width: 5px; border-radius: 50%; background: var(--primary); } -.app-footer { padding: 16px 36px; display: flex; justify-content: space-between; border-top: 1px solid var(--border); font-size: 10px; color: var(--muted-foreground); } -#collaboration { background: transparent; color: var(--foreground); } +.playground-caption { font-weight: 500; letter-spacing: .03em; } +.footer-save { display: flex; justify-content: flex-end; min-width: 0; text-align: right; } +.document-panel { display: flex; flex-direction: column; height: 100%; min-height: 360px; background: var(--card); } +.editor-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 20px; border-bottom: 1px solid var(--border); } +.document-title { font-size: 12px; font-weight: 500; } +.surface { min-width: 0; min-height: 0; flex: 1; overflow: auto; } +.editor-footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 20px; border-top: 1px solid var(--border); font-size: 11px; color: var(--muted-foreground); } +#collaboration { color: var(--foreground); background: var(--card); } #collaboration .document-panel { background: var(--card); } -.surface > .cm-editor { font-size: 15px; min-height: 440px; } +.sdk-page { padding: 24px; } +.sdk-page > [data-slot="card"] { max-width: 600px; margin: 24px auto; } +.surface > .cm-editor { font-size: 15px; height: 100%; } .surface > .cm-editor .cm-content { padding: 28px 30px 70px; font-family: inherit; line-height: 1.8; caret-color: var(--primary); } .surface .cm-focused { outline: none; } .surface .cm-placeholder { color: var(--muted-foreground); } @@ -283,6 +266,7 @@ .surface dialog::backdrop { background: #0006; } .surface .actions { display: flex; gap: 8px; margin-top: 14px; } .surface .kanban-board { min-height: 440px; } +.surface .excalidraw-shell { height: 100%; min-height: 380px; } .surface .card-open { width: 100%; text-align: left; border: 0; } .surface .card-list .kanban-card { background: var(--card); } .surface .kanban-card .card-open { background: transparent; } @@ -291,20 +275,29 @@ .surface .whiteboard-canvas { color: #223e33; } .surface .whiteboard-object { color: #223e33; } .surface .shared-table { color: #223e33; } -@media (max-width:1200px) { .app-frame { grid-template-columns: 190px minmax(0,1fr); } .main-content { padding: 28px 24px; } .workspace-grid { grid-template-columns: minmax(0,1fr); } .room-sidebar { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); } } -@media (max-width:800px) { .app-frame { grid-template-columns: minmax(0,1fr); } .app-sidebar { position: static; height: auto; padding: 14px 18px; border-right: 0; border-bottom: 1px solid var(--border); } .brand { margin: 0 0 14px; } .sidebar-section,.sidebar-home,.sidebar-bottom { display: none; } .example-navigation { flex-direction: row; overflow: auto; } .nav-item { flex: 0 0 auto; font-size: 11px; } .app-topbar { padding: 14px 20px; min-height: 60px; } .main-content { padding: 24px 18px; } .page-heading { align-items: start; } .page-heading>div+[data-slot="badge"] { display: none; } .welcome-grid,.rooms-grid { grid-template-columns: 1fr; gap: 28px; } .welcome-note { display: none; } .examples-grid { grid-template-columns: repeat(2,minmax(0,1fr)); } .room-sidebar { grid-template-columns: 1fr; } .workspace-status { flex-wrap: wrap; } .app-footer { padding: 15px 20px; } } +@media (max-width: 800px) { + .playground-shell { margin: 10px; height: calc(100dvh - 20px); padding: 0 16px; } + .playground-header { gap: 12px; min-height: 80px; } + .playground-people { gap: 8px; } + #account-bar { max-width: 140px; } + .room-trigger { max-width: 24vw; } + .playground-footer { gap: 8px; } + #status { max-width: 120px; } +} +@media (max-width: 560px) { + .playground-shell { margin: 0; height: 100dvh; border: 0; border-radius: 0; padding: 0 12px; } + .playground-header { flex-wrap: wrap; gap: 8px; padding: 12px 0; } + .playground-location { max-width: 100%; } + .room-trigger { max-width: 45vw; } + .playground-people { margin-left: auto; } + #account-bar { max-width: 180px; } + .playground-footer { grid-template-columns: minmax(0, 1fr) auto; gap: 2px 8px; padding: 8px 0; } + .playground-caption { grid-column: 1 / -1; grid-row: 2; text-align: center; font-size: 10px; } + .playground-lobby { padding: 24px 16px; } + .editor-toolbar, .editor-footer { flex-wrap: wrap; } +} @media (prefers-reduced-motion: reduce) { *,*::before,*::after { animation-duration: .01ms !important; transition-duration: .01ms !important; } } -/* Keep long account names within the header at narrow widths. */ -.app-sidebar, #account-bar { min-width: 0; } -#account-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -#participants li > .text-sm { min-width: 0; overflow: hidden; text-overflow: ellipsis; } .surface > .cm-editor .cm-scroller { font-family: var(--font-sans); } -@media (max-width:800px) { - .app-topbar { flex-wrap: wrap; } - #account-bar { max-width: 100%; } - #account-name { max-width: 160px; } - .editor-toolbar { flex-wrap: wrap; } -} /* A placeholder occupies document position zero without shifting peer carets. */ .surface .cm-line { position: relative; padding: 0; } .surface .cm-placeholder { position: absolute; inset-inline: 0; top: 0; pointer-events: none; } diff --git a/examples/collaboration/app/layout.tsx b/examples/collaboration/app/layout.tsx index 79dbb50..5e65c5a 100644 --- a/examples/collaboration/app/layout.tsx +++ b/examples/collaboration/app/layout.tsx @@ -1,8 +1,8 @@ import type { Metadata } from "next"; import "./globals.css"; export const metadata: Metadata = { - title: "Synixir · Work together", - description: "A shared workspace for writing, planning, and creating together.", + title: "Synixir Playground", + description: "Explore live collaboration in the Synixir playground.", }; export default function RootLayout({ children }: { children: React.ReactNode }) { return ( diff --git a/examples/collaboration/components/example-app.tsx b/examples/collaboration/components/example-app.tsx index 0c9d820..603c679 100644 --- a/examples/collaboration/components/example-app.tsx +++ b/examples/collaboration/components/example-app.tsx @@ -1,16 +1,6 @@ "use client"; import { useEffect, useRef, useState, type FormEvent } from "react"; -import { - ArrowUpRight, - ChevronRight, - CircleHelp, - FolderPlus, - LogOut, - Moon, - Sun, - Users, - Zap, -} from "lucide-react"; +import { ArrowUpRight, FolderPlus, WifiOff } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { @@ -21,10 +11,14 @@ import { CardContent, CardFooter, } from "@/components/ui/card"; -import { Field, FieldGroup, FieldLabel, FieldDescription } from "@/components/ui/field"; +import { + Field, + FieldGroup, + FieldLabel, + FieldDescription, +} from "@/components/ui/field"; import { Input } from "@/components/ui/input"; import { Alert, AlertDescription } from "@/components/ui/alert"; -import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Skeleton } from "@/components/ui/skeleton"; import { Empty, @@ -33,10 +27,17 @@ import { EmptyDescription, EmptyMedia, } from "@/components/ui/empty"; -import { Separator } from "@/components/ui/separator"; -import { example, examples, type ExampleKind } from "@/lib/examples"; -import { api, explain, sessionChanged, type User, type Session, type RoomSummary } from "@/lib/api"; -import { cn } from "@/lib/utils"; +import { example, type ExampleKind } from "@/lib/examples"; +import { + api, + explain, + sessionChanged, + type User, + type Session, + type RoomSummary, +} from "@/lib/api"; +import { AccountMenu, PlaygroundHeader } from "./playground-header"; +import { RoomDialog } from "./room-dialog"; import { Workspace } from "./workspace/workspace"; export function ExampleApp({ kind }: { kind: ExampleKind }) { @@ -67,8 +68,14 @@ export function ExampleApp({ kind }: { kind: ExampleKind }) { setUser(session.user); if (session.user && !id) setRooms( - (await api<{ data: RoomSummary[] }>("/api/rooms", "GET", undefined, controller.signal)) - .data, + ( + await api<{ data: RoomSummary[] }>( + "/api/rooms", + "GET", + undefined, + controller.signal, + ) + ).data, ); }) .catch((error) => { @@ -98,11 +105,16 @@ export function ExampleApp({ kind }: { kind: ExampleKind }) { async function authenticate(event: FormEvent) { event.preventDefault(); const values = Object.fromEntries(new FormData(event.currentTarget)); - const submitter = (event.nativeEvent as SubmitEvent).submitter as HTMLButtonElement; + const submitter = (event.nativeEvent as SubmitEvent) + .submitter as HTMLButtonElement; setPending(true); setError(""); try { - await api(submitter.value === "register" ? "/api/accounts" : "/api/session", "POST", values); + await api( + submitter.value === "register" ? "/api/accounts" : "/api/session", + "POST", + values, + ); sessionChanged(); location.reload(); } catch (error) { @@ -113,7 +125,9 @@ export function ExampleApp({ kind }: { kind: ExampleKind }) { async function signOut() { if ( unsaved.current() && - !confirm("Sign out and discard unsaved changes? Copy your draft first if you need it.") + !confirm( + "Sign out and discard unsaved changes? Copy your draft first if you need it.", + ) ) return; try { @@ -125,22 +139,6 @@ export function ExampleApp({ kind }: { kind: ExampleKind }) { setError(explain(error)); } } - async function createRoom(event: FormEvent) { - event.preventDefault(); - setPending(true); - setError(""); - try { - const result = await api<{ data: { id: string } }>( - "/api/rooms", - "POST", - Object.fromEntries(new FormData(event.currentTarget)), - ); - location.href = `${selected.path}?room=${encodeURIComponent(result.data.id)}`; - } catch (error) { - setError(explain(error)); - setPending(false); - } - } function theme() { const value = !dark; setDark(value); @@ -152,299 +150,200 @@ export function ExampleApp({ kind }: { kind: ExampleKind }) { } } + const accountMenu = ( + + ); + const accountError = error ? ( + + {error} + + ) : ( + + ); + return ( -
- -
-
-
- Workspace - - {selected.title} -
- {user ? ( -
- - {user.username.slice(0, 2).toUpperCase()} - - - {user.username} - - -
- ) : ( - Live collaboration - )} -
-
-
-
-

BETTER TOGETHER

-

{selected.title}

-

{selected.description}

-
- - - Shared in real time - -
- {error && ( - - {error} - - )} - {!error && } - {loading ? ( -
- - -
- ) : !user ? ( -
- - - - Welcome to your shared workspace - - - Sign in or create an account to start collaborating. - - - -
- - - Username - - - - Password - - - Use at least 15 characters. Usernames use letters, numbers, underscores or - hyphens. - - -
- - -
-
-
-
- -

- Your account works across every example. -

-
-
-
-
+
+ + + Offline + + Playground + + {user ? "Open a room to start" : "Sign in to collaborate"} + +
+ + )}
); } diff --git a/examples/collaboration/components/playground-header.tsx b/examples/collaboration/components/playground-header.tsx new file mode 100644 index 0000000..47b72be --- /dev/null +++ b/examples/collaboration/components/playground-header.tsx @@ -0,0 +1,125 @@ +"use client"; + +import type { ReactNode } from "react"; +import { Check, ChevronDown, LogOut, Moon, Sun } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { example, examples, type ExampleKind } from "@/lib/examples"; +import type { User } from "@/lib/api"; + +export function AccountMenu({ + user, + dark, + onTheme, + onSignOut, +}: { + user: User | null; + dark: boolean; + onTheme(): void; + onSignOut(): void; +}) { + return user ? ( + + + + + + My account + + + {dark ? : } + {dark ? "Use light theme" : "Use dark theme"} + + + + + + + Sign out + + + + + ) : ( + + ); +} + +export function PlaygroundHeader({ + kind, + roomId, + roomControl, + membersControl, + accountMenu, +}: { + kind: ExampleKind; + roomId?: string | null; + roomControl: ReactNode; + membersControl?: ReactNode; + accountMenu: ReactNode; +}) { + const selected = example(kind); + return ( +
+ +
+ {membersControl} + {accountMenu} +
+
+ ); +} diff --git a/examples/collaboration/components/room-dialog.tsx b/examples/collaboration/components/room-dialog.tsx new file mode 100644 index 0000000..826746b --- /dev/null +++ b/examples/collaboration/components/room-dialog.tsx @@ -0,0 +1,191 @@ +"use client"; + +import { useState, type FormEvent, type ReactNode } from "react"; +import { ArrowUpRight, FolderPlus, Hash } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Field, + FieldDescription, + FieldGroup, + FieldLabel, +} from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { api, explain, type RoomSummary } from "@/lib/api"; +import { example, type ExampleKind } from "@/lib/examples"; + +export function RoomDialog({ + kind, + roomId, + rooms = [], + children, + trigger, + defaultTab = "info", + onOpen, +}: { + kind: ExampleKind; + roomId?: string | null; + rooms?: RoomSummary[]; + children?: ReactNode; + trigger?: ReactNode; + defaultTab?: "info" | "create"; + onOpen?(): void; +}) { + const selected = example(kind); + const [pending, setPending] = useState(false); + const [error, setError] = useState(""); + + async function createRoom(event: FormEvent) { + event.preventDefault(); + setPending(true); + setError(""); + try { + const result = await api<{ data: { id: string } }>( + "/api/rooms", + "POST", + Object.fromEntries(new FormData(event.currentTarget)), + ); + location.href = `${selected.path}?room=${encodeURIComponent(result.data.id)}`; + } catch (error) { + setError(explain(error)); + } finally { + setPending(false); + } + } + + return ( + { + if (open) onOpen?.(); + }} + > + + {trigger ?? ( + + )} + + + + Rooms + + Share a room across all the playground examples. + + + + + Room info + Create a room + + + {roomId ? ( + <> +
+ +

{roomId}

+
+ {children} +
+ + + Room ID + + + An owner must grant access before you can open another + room. + + + + +
+ + + ) : ( + <> +

Your rooms

+ {rooms.length ? ( + + ) : ( +

+ No rooms yet. Create one in the next tab, or ask an owner to + invite you. +

+ )} + + )} +
+ +
+ + + New room ID + + + Use letters, numbers, underscores, and hyphens. You will be + the room owner. + + + {error ? ( + + {error} + + ) : null} + + +
+
+
+
+
+ ); +} diff --git a/examples/collaboration/components/ui/alert-dialog.tsx b/examples/collaboration/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..ff071fa --- /dev/null +++ b/examples/collaboration/components/ui/alert-dialog.tsx @@ -0,0 +1,199 @@ +"use client"; + +import * as React from "react"; +import { cn } from "cn"; +import { AlertDialog as AlertDialogPrimitive } from "radix-ui"; + +import { Button } from "@/components/ui/button"; + +function AlertDialog({ + ...props +}: React.ComponentProps) { + return ; +} + +function AlertDialogTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogPortal({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogContent({ + className, + size = "default", + ...props +}: React.ComponentProps & { + size?: "default" | "sm"; +}) { + return ( + + + + + ); +} + +function AlertDialogHeader({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function AlertDialogFooter({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function AlertDialogMedia({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function AlertDialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogAction({ + className, + variant = "default", + size = "default", + ...props +}: React.ComponentProps & + Pick, "variant" | "size">) { + return ( + + ); +} + +function AlertDialogCancel({ + className, + variant = "outline", + size = "default", + ...props +}: React.ComponentProps & + Pick, "variant" | "size">) { + return ( + + ); +} + +export { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogMedia, + AlertDialogOverlay, + AlertDialogPortal, + AlertDialogTitle, + AlertDialogTrigger, +}; diff --git a/examples/collaboration/components/ui/dialog.tsx b/examples/collaboration/components/ui/dialog.tsx new file mode 100644 index 0000000..8599f8a --- /dev/null +++ b/examples/collaboration/components/ui/dialog.tsx @@ -0,0 +1,167 @@ +"use client"; + +import * as React from "react"; +import { cn } from "cn"; +import { Dialog as DialogPrimitive } from "radix-ui"; + +import { Button } from "@/components/ui/button"; +import { XIcon } from "lucide-react"; + +function Dialog({ + ...props +}: React.ComponentProps) { + return ; +} + +function DialogTrigger({ + ...props +}: React.ComponentProps) { + return ; +} + +function DialogPortal({ + ...props +}: React.ComponentProps) { + return ; +} + +function DialogClose({ + ...props +}: React.ComponentProps) { + return ; +} + +function DialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: React.ComponentProps & { + showCloseButton?: boolean; +}) { + return ( + + + + {children} + {showCloseButton && ( + + + + )} + + + ); +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function DialogFooter({ + className, + showCloseButton = false, + children, + ...props +}: React.ComponentProps<"div"> & { + showCloseButton?: boolean; +}) { + return ( +
+ {children} + {showCloseButton && ( + + + + )} +
+ ); +} + +function DialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +}; diff --git a/examples/collaboration/components/ui/dropdown-menu.tsx b/examples/collaboration/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..a510778 --- /dev/null +++ b/examples/collaboration/components/ui/dropdown-menu.tsx @@ -0,0 +1,272 @@ +"use client"; + +import * as React from "react"; +import { cn } from "cn"; +import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"; +import { CheckIcon, ChevronRightIcon } from "lucide-react"; + +function DropdownMenu({ + ...props +}: React.ComponentProps) { + return ; +} + +function DropdownMenuPortal({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DropdownMenuTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DropdownMenuContent({ + className, + align = "start", + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +function DropdownMenuGroup({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DropdownMenuItem({ + className, + inset, + variant = "default", + ...props +}: React.ComponentProps & { + inset?: boolean; + variant?: "default" | "destructive"; +}) { + return ( + + ); +} + +function DropdownMenuCheckboxItem({ + className, + children, + checked, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean; +}) { + return ( + + + + + + + {children} + + ); +} + +function DropdownMenuRadioGroup({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DropdownMenuRadioItem({ + className, + children, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean; +}) { + return ( + + + + + + + {children} + + ); +} + +function DropdownMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean; +}) { + return ( + + ); +} + +function DropdownMenuSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DropdownMenuShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ); +} + +function DropdownMenuSub({ + ...props +}: React.ComponentProps) { + return ; +} + +function DropdownMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean; +}) { + return ( + + {children} + + + ); +} + +function DropdownMenuSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { + DropdownMenu, + DropdownMenuPortal, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuLabel, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent, +}; diff --git a/examples/collaboration/components/ui/tabs.tsx b/examples/collaboration/components/ui/tabs.tsx new file mode 100644 index 0000000..19965e5 --- /dev/null +++ b/examples/collaboration/components/ui/tabs.tsx @@ -0,0 +1,89 @@ +"use client"; + +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "cn"; +import { Tabs as TabsPrimitive } from "radix-ui"; + +function Tabs({ + className, + orientation = "horizontal", + ...props +}: React.ComponentProps) { + return ( + + ); +} + +const tabsListVariants = cva( + "group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none", + { + variants: { + variant: { + default: "bg-muted", + line: "gap-1 bg-transparent", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +function TabsList({ + className, + variant = "default", + ...props +}: React.ComponentProps & + VariantProps) { + return ( + + ); +} + +function TabsTrigger({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function TabsContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }; diff --git a/examples/collaboration/components/workspace/workspace.tsx b/examples/collaboration/components/workspace/workspace.tsx index f2ce699..2fd77c4 100644 --- a/examples/collaboration/components/workspace/workspace.tsx +++ b/examples/collaboration/components/workspace/workspace.tsx @@ -1,14 +1,65 @@ "use client"; import { inspectForTest } from "@/lib/browser-test"; -import { memo, useEffect, useRef, useState, type FormEvent, type RefObject } from "react"; +import { + memo, + useEffect, + useRef, + useState, + type FormEvent, + type RefObject, + type ReactNode, +} from "react"; import { SynixirRoom, type RoomState } from "@synixir/client"; -import { ExternalLink, ShieldCheck, Undo2, Redo2, Users } from "lucide-react"; +import { + Check, + CircleHelp, + ExternalLink, + Plus, + ShieldCheck, + Undo2, + Redo2, + Users, + Wifi, + WifiOff, +} from "lucide-react"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; -import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/components/ui/card"; +import { + Card, + CardHeader, + CardTitle, + CardDescription, + CardContent, +} from "@/components/ui/card"; import { FieldGroup, Field, FieldLabel } from "@/components/ui/field"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { PlaygroundHeader } from "@/components/playground-header"; +import { RoomDialog } from "@/components/room-dialog"; +import { cn } from "@/lib/utils"; import { Input } from "@/components/ui/input"; -import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select"; +import { + NativeSelect, + NativeSelectOption, +} from "@/components/ui/native-select"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { api, explain, type User, type Member, type Session } from "@/lib/api"; import { example, type ExampleKind } from "@/lib/examples"; @@ -57,7 +108,9 @@ const errors: Record = { timeout: "The save acknowledgement did not arrive. Disconnect and connect again to confirm your edits. Keep this tab open.", }; -async function loadSurface(kind: ExampleKind): Promise<(room: SynixirRoom) => Surface> { +async function loadSurface( + kind: ExampleKind, +): Promise<(room: SynixirRoom) => Surface> { switch (kind) { case "kanban": return (await import("@/kanban/board")).createBoard; @@ -73,7 +126,8 @@ async function loadSurface(kind: ExampleKind): Promise<(room: SynixirRoom) => Su return (await import("@/rich-text/editor")).createRichText; default: { const { createEditor } = await import("@/editor"); - return (room) => createEditor(room.doc.getText("content"), room.awareness); + return (room) => + createEditor(room.doc.getText("content"), room.awareness); } } } @@ -81,17 +135,15 @@ async function loadSurface(kind: ExampleKind): Promise<(room: SynixirRoom) => Su const SurfaceHost = memo(function SurfaceHost({ title, hint, - roomId, }: { title: string; hint: string; - roomId: string; }) { return (
- {roomId} + {title}
@@ -124,19 +176,35 @@ interface Props { user: User; cleanupRef: RefObject<() => void>; unsavedRef: RefObject<() => boolean>; + accountMenu: ReactNode; + accountError: ReactNode; } -export function Workspace({ kind, roomId, user, cleanupRef, unsavedRef }: Props) { +export function Workspace({ + kind, + roomId, + user, + cleanupRef, + unsavedRef, + accountMenu, + accountError, +}: Props) { const selected = example(kind); const [state, setState] = useState(initial); const [peers, setPeers] = useState([]); const [members, setMembers] = useState([]); const [memberError, setMemberError] = useState(""); + const [memberPending, setMemberPending] = useState(false); + const [membersLoaded, setMembersLoaded] = useState(false); const [startupError, setStartupError] = useState(""); const [title, setTitle] = useState(""); const roomRef = useRef(null); const surfaceRef = useRef(null); - const frozen = state.error?.code === "access_changed" || state.error?.code === "account_changed"; - const active = ["connected", "connecting", "reconnecting"].includes(state.connection); + const frozen = + state.error?.code === "access_changed" || + state.error?.code === "account_changed"; + const active = ["connected", "connecting", "reconnecting"].includes( + state.connection, + ); useEffect(() => { let disposed = false; @@ -189,10 +257,16 @@ export function Workspace({ kind, roomId, user, cleanupRef, unsavedRef }: Props) const presence = () => { const online = room.state.connection === "connected"; const list: Peer[] = [...room.awareness.getStates()] - .filter(([id, value]) => value.user && (online || id === room.awareness.clientID)) + .filter( + ([id, value]) => + value.user && (online || id === room.awareness.clientID), + ) .map(([id, value]) => ({ id, - name: typeof value.user.name === "string" ? value.user.name.slice(0, 32) : "Guest", + name: + typeof value.user.name === "string" + ? value.user.name.slice(0, 32) + : "Guest", local: id === room.awareness.clientID, })) .sort((a, b) => (a.local ? -1 : b.local ? 1 : a.id - b.id)); @@ -213,7 +287,10 @@ export function Workspace({ kind, roomId, user, cleanupRef, unsavedRef }: Props) if (next.error?.code === "account_changed") { dispose(); location.reload(); - } else if (next.error?.code === "access_changed" && !checkedRevocation) { + } else if ( + next.error?.code === "access_changed" && + !checkedRevocation + ) { checkedRevocation = true; void api("/api/session") .then((session) => { @@ -238,16 +315,26 @@ export function Workspace({ kind, roomId, user, cleanupRef, unsavedRef }: Props) const memberPath = `/api/rooms/${encodeURIComponent(roomId)}/members`; async function loadMembers() { + if (!state.role || frozen) return; try { - setMembers((await api<{ data: Member[] }>(memberPath)).data); + setMembers( + ( + await api<{ data: { members: Member[] } }>( + `/api/rooms/${encodeURIComponent(roomId)}`, + ) + ).data.members, + ); + setMembersLoaded(true); + setMemberError(""); } catch (error) { setMemberError(explain(error)); } } useEffect(() => { - if (state.role === "owner") void loadMembers(); - }, [state.role, memberPath]); + if (state.role && !frozen) void loadMembers(); + }, [state.role, memberPath, frozen]); async function changeMember(username: string, nextRole: string | null) { + setMemberPending(true); try { await api( `${memberPath}/${encodeURIComponent(username)}`, @@ -258,6 +345,8 @@ export function Workspace({ kind, roomId, user, cleanupRef, unsavedRef }: Props) await loadMembers(); } catch (error) { setMemberError(explain(error)); + } finally { + setMemberPending(false); } } function grant(event: FormEvent) { @@ -279,7 +368,8 @@ export function Workspace({ kind, roomId, user, cleanupRef, unsavedRef }: Props) ? "Access expired or denied" : state.error?.code === "timeout" ? "Connection timed out" - : (errors[state.error?.code ?? ""] ?? "Document sync failed. Keep this tab open.")); + : (errors[state.error?.code ?? ""] ?? + "Document sync failed. Keep this tab open.")); const saveHelp = state.saveStatus === "failed" ? (errors[state.saveError ?? ""] ?? @@ -288,237 +378,387 @@ export function Workspace({ kind, roomId, user, cleanupRef, unsavedRef }: Props) ? "Keep this tab open until Saved appears. Offline edits stay in this tab until you reconnect." : "Saved edits stay in this room after everyone leaves."; - return ( -
-
-
- - - {state.role ?? "Checking access"} - - - {saveLabels[state.saveStatus]} - -
-
- - {connectionLabel} - - -
+ const online = state.connection === "connected"; + const onlineNames = new Set(online ? peers.map((peer) => peer.name) : []); + const roster = membersLoaded + ? members.map((member) => ({ name: member.username, role: member.role })) + : [...new Set(peers.map((peer) => peer.name))].map((name) => ({ + name, + role: name === user.username ? state.role : null, + })); + const connectionAction = frozen + ? "Reload" + : active + ? "Disconnect" + : "Connect"; + const connectionHelp = online + ? "You are online. Changes and presence are shared with everyone in this room." + : frozen + ? "Your access has changed. Reload to check your current permissions. Copy any unsaved draft before reloading." + : state.connection === "error" + ? "The room could not connect. Review the message above before trying again." + : active + ? "Connecting to the room. Your draft stays in this tab while the connection is restored." + : "You are offline. Keep this tab open to preserve any unsaved edits."; + const roomInfo = ( + <> +
+ + + {state.role ?? "Checking access"} + + + {membersLoaded + ? `${members.length} ${members.length === 1 ? "member" : "members"} · ${onlineNames.size} online` + : "Loading members…"} +
- {startupError && ( -

- {startupError} -

- )} -
-
- {kind === "sdk" ? ( - - - - Shared settings - - - A small example of synchronized application state. - - - - - - Room title - { - if (!roomRef.current?.state.readOnly) - roomRef.current?.doc - .getMap("settings") - .set("title", event.target.value); - }} - /> - -

- {state.connection} · {state.saveStatus} - {state.error ? ` · ${state.error.code}` : ""} -

- -
-
-
+
    + {roster.map((member) => ( +
  • + + + {member.name.slice(0, 2).toUpperCase()} + + +
    +

    + {member.name} + {member.name === user.username ? " (you)" : ""} +

    + {member.role ? ( +

    {member.role}

    + ) : null} +
    + + + {onlineNames.has(member.name) ? "Online" : "Offline"} + +
  • + ))} +
+ {memberError ? ( + + {memberError} + + ) : null} + + + ); + const membersControl = ( + { + if (open) void loadMembers(); + }} + > + +
- -
-
+ + + + {saveHelp} + +
+ +
    + {peers.map((peer) => ( +
  • + {peer.name} ·{" "} + {peer.local ? (online ? "You" : "You · offline") : "Online"} +
  • + ))} +
+ + {user.username} + + ); } diff --git a/examples/collaboration/flowchart/canvas.tsx b/examples/collaboration/flowchart/canvas.tsx index 8807966..95114fe 100644 --- a/examples/collaboration/flowchart/canvas.tsx +++ b/examples/collaboration/flowchart/canvas.tsx @@ -188,7 +188,6 @@ function Canvas({ adapter, presence }: { adapter: FlowchartAdapter; presence: Fl export function createFlowchart(room: SynixirRoom) { const host = document.querySelector("#editor")!; - document.querySelector("#open-peer")!.href = window.location.href; const adapter = createFlowchartAdapter(createFlowchartModel(room.doc)); const presence = createFlowchartPresence(room, adapter); const root = createRoot(host); diff --git a/examples/collaboration/kanban/board.ts b/examples/collaboration/kanban/board.ts index ab4e0cb..f7f9e4d 100644 --- a/examples/collaboration/kanban/board.ts +++ b/examples/collaboration/kanban/board.ts @@ -4,7 +4,6 @@ import { mountBoardView } from "./board-view.tsx"; export function createBoard(room: SynixirRoom) { const root = document.querySelector("#editor")!; - document.querySelector("#open-peer")!.href = window.location.href; const model = createBoardModel(room.doc); const events = new AbortController(); const listen = (target: EventTarget, event: K, handler: (event: DocumentEventMap[K]) => void, capture = false) => target.addEventListener(event, handler as EventListener, { signal: events.signal, capture }); diff --git a/examples/collaboration/multiplayer-form/form.ts b/examples/collaboration/multiplayer-form/form.ts index b662960..c4aa8d4 100644 --- a/examples/collaboration/multiplayer-form/form.ts +++ b/examples/collaboration/multiplayer-form/form.ts @@ -9,7 +9,6 @@ import { createBriefFormState, type FormFieldName } from "./form-state.ts"; export function createMultiplayerForm(room: SynixirRoom) { const root = document.querySelector("#editor")!; - document.querySelector("#open-peer")!.href = window.location.href; const model = createFormModel(room.doc); const briefForm = createBriefFormState(room.doc, model); const shell = createFormShell(root); diff --git a/examples/collaboration/next.config.ts b/examples/collaboration/next.config.ts index 6ff2eaa..7724341 100644 --- a/examples/collaboration/next.config.ts +++ b/examples/collaboration/next.config.ts @@ -1,5 +1,6 @@ import type { NextConfig } from "next"; const config: NextConfig = { + distDir: process.env.SYNIXIR_NEXT_DIST_DIR ?? ".next", output: "export", images: { unoptimized: true }, poweredByHeader: false, diff --git a/examples/collaboration/playwright.config.ts b/examples/collaboration/playwright.config.ts index e620938..3adfead 100644 --- a/examples/collaboration/playwright.config.ts +++ b/examples/collaboration/playwright.config.ts @@ -13,7 +13,11 @@ export default defineConfig({ webServer: [ { command: `npm run dev -- --port ${frontendPort}`, - env: { NEXT_PUBLIC_SYNIXIR_BROWSER_TEST: "true", SYNIXIR_ENDPOINT: backendURL }, + env: { + NEXT_PUBLIC_SYNIXIR_BROWSER_TEST: "true", + SYNIXIR_ENDPOINT: backendURL, + SYNIXIR_NEXT_DIST_DIR: ".next-browser-test", + }, url: frontendURL, reuseExistingServer: false, gracefulShutdown: { signal: "SIGTERM", timeout: 5_000 }, diff --git a/examples/collaboration/rich-text/editor.ts b/examples/collaboration/rich-text/editor.ts index a6b4c65..f9959ee 100644 --- a/examples/collaboration/rich-text/editor.ts +++ b/examples/collaboration/rich-text/editor.ts @@ -61,7 +61,6 @@ export function createRichText(room: SynixirRoom) {
`; - document.querySelector("#open-peer")!.href = window.location.href; const toolbar = root.querySelector(".format-toolbar")!; const style = root.querySelector("#text-style")!; const dialog = root.querySelector("dialog")!; diff --git a/examples/collaboration/table/table.ts b/examples/collaboration/table/table.ts index b490cd6..9404ed7 100644 --- a/examples/collaboration/table/table.ts +++ b/examples/collaboration/table/table.ts @@ -6,7 +6,6 @@ import { TableSession } from "./session.ts"; export function createCollaborativeTable(room: SynixirRoom) { const host = document.querySelector("#editor")!; - document.querySelector("#open-peer")!.href = location.href; const session = new TableSession(room); const root = createRoot(host); root.render(createElement(CollaborativeTable, { session })); diff --git a/examples/collaboration/tests/collaboration.spec.ts b/examples/collaboration/tests/collaboration.spec.ts index dccdd7d..5369dfb 100644 --- a/examples/collaboration/tests/collaboration.spec.ts +++ b/examples/collaboration/tests/collaboration.spec.ts @@ -96,7 +96,7 @@ test("awareness shows participants and selections and removes them on departure" await insertAtStart(bob, "Before "); await expectText(alice, "Before Hello everyone"); await expect(bob.locator(".cm-ySelection")).toHaveText("Hello"); - await alice.getByLabel("Room ID", { exact: true }).focus(); + await alice.getByRole("button", { name: "Select example", exact: true }).focus(); await expect(bob.locator(".cm-ySelectionCaret")).toHaveCount(0); await alice.getByRole("button", { name: "Disconnect", exact: true }).click(); @@ -126,6 +126,7 @@ test("offline edits prompt before leaving and survive cancelling room navigation await page.getByRole("button", { name: "Disconnect", exact: true }).click(); await insertAtStart(page, "Keep this draft"); await expect(page.locator("#save-status")).toHaveText("Unsaved changes"); + await page.getByRole("button", { name: `Room details: ${room}` }).click(); await page.getByLabel("Room ID", { exact: true }).fill(`other-${randomUUID()}`); const dialogPromise = page.waitForEvent("dialog"); await page.getByRole("button", { name: "Open room", exact: true }).click({ noWaitAfter: true }); @@ -133,6 +134,7 @@ test("offline edits prompt before leaving and survive cancelling room navigation expect(dialog.type()).toBe("beforeunload"); await dialog.dismiss(); await expect(page).toHaveURL(`${baseURL}/?room=${room}`); + await page.getByRole("button", { name: "Close", exact: true }).click(); await expectText(page, "Keep this draft"); await page.getByRole("button", { name: "Connect", exact: true }).click(); await expect(page.locator("#save-status")).toHaveText("Saved"); diff --git a/examples/collaboration/tests/flowchart.spec.ts b/examples/collaboration/tests/flowchart.spec.ts index e0f6abd..267aa9b 100644 --- a/examples/collaboration/tests/flowchart.spec.ts +++ b/examples/collaboration/tests/flowchart.spec.ts @@ -107,7 +107,8 @@ test("flowchart merges offline edits, restores nodes and arrows after restart, a const fresh = await context.newPage(); await fresh.goto(url); await saved(fresh); await expect.poll(() => position(node(fresh, "Process: Draft"))).toEqual(moved); await expect(fresh.locator(".flow-edge-label")).toHaveText("Approved"); - await fresh.getByRole("link", { name: "Try the whiteboard", exact: true }).click(); + await fresh.getByRole("button", { name: "Select example", exact: true }).click(); + await fresh.getByRole("menuitem", { name: "Whiteboard", exact: true }).click(); await expect(fresh).toHaveURL(`${baseURL}/whiteboard.html?room=${roomId}`); await saved(fresh); await expect(fresh.locator(".whiteboard-object")).toHaveCount(0); }); diff --git a/examples/collaboration/tests/kanban.spec.ts b/examples/collaboration/tests/kanban.spec.ts index bcd63fa..2abf71c 100644 --- a/examples/collaboration/tests/kanban.spec.ts +++ b/examples/collaboration/tests/kanban.spec.ts @@ -135,6 +135,7 @@ test("board navigation stays on Kanban and the layout fits mobile", async ({ pag await register(page.request, baseURL); await page.goto(`${baseURL}/kanban.html`); const roomId = `navigation-${randomUUID()}`; + await page.getByRole("button", { name: "Create a room", exact: true }).click(); await page.getByLabel("New room ID").fill(roomId); await page.getByRole("button", { name: "Create room", exact: true }).click(); await expect(page).toHaveURL(`${baseURL}/kanban.html?room=${roomId}`); @@ -148,7 +149,10 @@ test("board navigation stays on Kanban and the layout fits mobile", async ({ pag await expect(page.locator('[data-column="done"]')).toContainText("Mobile planning"); expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); await page.screenshot({ path: testInfo.outputPath("kanban-mobile.png"), fullPage: true }); - await expect(page.getByRole("link", { name: "Try the text editor" })).toHaveJSProperty("href", `${baseURL}/?room=${roomId}`); + await page.getByRole("button", { name: "Select example", exact: true }).click(); + await expect(page.getByRole("menuitem", { name: "Text editor", exact: true })).toHaveJSProperty("href", `${baseURL}/?room=${roomId}`); + await page.keyboard.press("Escape"); + await page.getByRole("button", { name: `Room details: ${roomId}` }).click(); await page.getByRole("link", { name: "All rooms" }).click(); await expect(page).toHaveURL(`${baseURL}/kanban.html`); await page.getByRole("link", { name: `${roomId} · owner`, exact: true }).click(); diff --git a/examples/collaboration/tests/permissions.spec.ts b/examples/collaboration/tests/permissions.spec.ts index ce67415..a9b8585 100644 --- a/examples/collaboration/tests/permissions.spec.ts +++ b/examples/collaboration/tests/permissions.spec.ts @@ -16,10 +16,13 @@ test("owners manage private rooms and viewers cannot edit", async ({ page, brows await signIn(page, username("owner"), "Create account"); await expect(page.getByRole("heading", { name: "Your rooms", exact: true })).toBeVisible(); const room = `private-${randomUUID()}`; + await page.getByRole("button", { name: "Create a room", exact: true }).click(); await page.getByLabel("New room ID").fill(room); await page.getByRole("button", { name: "Create room", exact: true }).click(); await connected(page); + await page.getByRole("button", { name: `Room details: ${room}` }).click(); await expect(page.locator("#role-label")).toHaveText("owner"); + await page.getByRole("button", { name: "Close", exact: true }).click(); await insertAtStart(page, "Owner's saved text"); await expect(page.locator("#save-status")).toHaveText("Saved"); const viewerContext = await browser.newContext(); @@ -29,6 +32,7 @@ test("owners manage private rooms and viewers cannot edit", async ({ page, brows await reader.goto(`${baseURL}/?room=${room}`); await expect(reader.locator("#status")).toHaveText("Access expired or denied"); await expectText(reader, ""); + await page.getByRole("button", { name: "Manage access", exact: true }).click(); await page.getByLabel("Account username", { exact: true }).fill(viewer.username); await page.getByLabel("Role", { exact: true }).selectOption("viewer"); await page.getByRole("button", { name: "Grant access", exact: true }).click(); @@ -47,9 +51,12 @@ test("owners manage private rooms and viewers cannot edit", async ({ page, brows await connected(reader); await insertAtStart(reader, "Editor: "); await expect(reader.locator("#save-status")).toHaveText("Saved"); + await page.getByRole("button", { name: "Close", exact: true }).click(); await expectText(page, "Editor: Owner's saved text"); + await page.getByRole("button", { name: "Manage access", exact: true }).click(); await page.getByRole("button", { name: `Remove ${viewer.username}`, exact: true }).click(); await expect(reader.locator("#status")).toContainText("Access changed"); + await page.getByRole("button", { name: "Close", exact: true }).click(); await insertAtStart(page, "Private again. "); await expect(page.locator("#save-status")).toHaveText("Saved"); await expectText(reader, "Editor: Owner's saved text"); @@ -71,7 +78,8 @@ test("signing out clears every tab before a different account signs in", async ( const second = await context.newPage(); await second.goto(url); await connected(second); - await page.getByRole("button", { name: "Sign out", exact: true }).click(); + await page.getByRole("button", { name: "Account menu", exact: true }).click(); + await page.getByRole("menuitem", { name: "Sign out", exact: true }).click(); for (const tab of [page, second]) { await expect(tab.locator("#auth-panel")).toBeVisible(); await expect(tab.locator("#editor")).toHaveCount(0); @@ -79,7 +87,8 @@ test("signing out clears every tab before a different account signs in", async ( await signIn(page, username("other"), "Create account"); await expect(page.locator("#status")).toHaveText("Access expired or denied"); await expectText(page, ""); - await page.getByRole("button", { name: "Sign out", exact: true }).click(); + await page.getByRole("button", { name: "Account menu", exact: true }).click(); + await page.getByRole("menuitem", { name: "Sign out", exact: true }).click(); await signIn(page, owner.username); await connected(page); await expectText(page, "Private account document"); diff --git a/examples/collaboration/tests/playground.spec.ts b/examples/collaboration/tests/playground.spec.ts new file mode 100644 index 0000000..39d7467 --- /dev/null +++ b/examples/collaboration/tests/playground.spec.ts @@ -0,0 +1,168 @@ +import { randomUUID } from "node:crypto"; +import { test, expect } from "./fixtures.ts"; +import { api, register } from "./access-helpers.ts"; +import { connected, insertAtStart } from "./editor-helpers.ts"; + +test("playground menus, room tabs, connection alerts and save help work together", async ({ + page, + baseURL, +}, testInfo) => { + await register(page.request, baseURL); + await page.setViewportSize({ width: 1440, height: 1000 }); + await page.goto(baseURL!); + await page + .getByRole("button", { name: "Choose a room", exact: true }) + .click(); + await expect( + page.getByRole("tab", { name: "Room info", exact: true }), + ).toHaveAttribute("aria-selected", "true"); + await page.getByRole("tab", { name: "Create a room", exact: true }).click(); + const roomId = `playground-${randomUUID()}`; + await page.getByLabel("New room ID").fill(roomId); + await page.getByRole("button", { name: "Create room", exact: true }).click(); + await connected(page); + await insertAtStart(page, "A shared playground draft"); + await expect(page.locator("#save-status")).toHaveText("Saved"); + + await page + .getByRole("button", { name: "Connection status", exact: true }) + .click(); + const alert = page.getByRole("alertdialog", { + name: "Connection status", + exact: true, + }); + await expect(alert).toContainText("Connected"); + await alert.getByRole("button", { name: "Disconnect", exact: true }).click(); + await expect(alert).toBeHidden(); + await expect(page.locator("#status")).toHaveText("Disconnected"); + await insertAtStart(page, "Offline edit. "); + await page + .getByRole("button", { name: "Save status and help", exact: true }) + .click(); + await expect(page.getByRole("dialog")).toContainText( + "Offline edits stay in this tab until you reconnect.", + ); + await page.getByRole("button", { name: "Close", exact: true }).click(); + await page.getByRole("button", { name: "Connect", exact: true }).click(); + await connected(page); + await expect(page.locator("#save-status")).toHaveText("Saved"); + + await page + .getByRole("button", { name: "Select example", exact: true }) + .click(); + await expect(page.getByRole("menuitem")).toHaveCount(8); + await expect( + page.getByRole("menuitem", { name: "Kanban board", exact: true }), + ).toHaveAttribute("href", `/kanban.html?room=${roomId}`); + await page + .getByRole("menuitem", { name: "Kanban board", exact: true }) + .click(); + await connected(page); + await expect(page.locator(".kanban-board")).toBeVisible(); + await page.screenshot({ + path: testInfo.outputPath("playground-desktop.png"), + }); + + await page.getByRole("button", { name: `Room details: ${roomId}` }).click(); + await expect(page.getByRole("dialog")).toContainText("1 member · 1 online"); + await expect(page.getByRole("list", { name: "Room members" })).toContainText( + "Online", + ); + await page.getByRole("tab", { name: "Create a room", exact: true }).click(); + await page.getByLabel("New room ID").fill(roomId); + await page.getByRole("button", { name: "Create room", exact: true }).click(); + await expect(page.getByRole("alert")).toContainText( + "That room ID is unavailable.", + ); + await page.getByRole("button", { name: "Close", exact: true }).click(); + + await expect(page.getByRole("dialog")).toBeHidden(); + await page.setViewportSize({ width: 390, height: 844 }); + expect( + await page.evaluate( + () => document.documentElement.scrollWidth <= innerWidth, + ), + ).toBe(true); + await page.screenshot({ path: testInfo.outputPath("playground-mobile.png") }); + await page.getByRole("button", { name: "Account menu", exact: true }).click(); + await page + .getByRole("menuitem", { name: "Use dark theme", exact: true }) + .click(); + await expect(page.locator("html")).toHaveClass("dark"); + await page.getByRole("button", { name: "Account menu", exact: true }).click(); + await page.getByRole("menuitem", { name: "Sign out", exact: true }).click(); + await expect(page.locator("#auth-panel")).toBeVisible(); +}); + +test("viewers see the full member roster and live online status without access controls", async ({ + page, + browser, + baseURL, +}) => { + const owner = await register(page.request, baseURL); + const roomId = `roster-${randomUUID()}`; + expect( + ( + await api(page.request, baseURL, "/api/rooms", "POST", { + room_id: roomId, + }) + ).ok(), + ).toBe(true); + const viewerContext = await browser.newContext(); + try { + const viewer = await register(viewerContext.request, baseURL); + expect( + ( + await api( + page.request, + baseURL, + `/api/rooms/${roomId}/members/${viewer.username}`, + "PUT", + { role: "viewer" }, + ) + ).ok(), + ).toBe(true); + const reader = await viewerContext.newPage(); + await reader.goto(`${baseURL}/?room=${roomId}`); + await connected(reader); + await reader + .getByRole("button", { name: `Room details: ${roomId}` }) + .click(); + const roster = reader.getByRole("list", { name: "Room members" }); + await expect(roster.getByRole("listitem")).toHaveCount(2); + await expect( + roster.getByRole("listitem").filter({ hasText: owner.username }), + ).toContainText("Offline"); + await expect( + roster.getByRole("listitem").filter({ hasText: viewer.username }), + ).toContainText("Online"); + await page.goto(`${baseURL}/?room=${roomId}`); + await connected(page); + await expect( + roster.getByRole("listitem").filter({ hasText: owner.username }), + ).toContainText("Online"); + await reader.getByRole("button", { name: "Close", exact: true }).click(); + await reader + .getByRole("button", { name: "Manage access", exact: true }) + .click(); + await expect(reader.getByRole("dialog")).toContainText( + "Only room owners can change access.", + ); + await expect( + reader.getByRole("button", { name: "Grant access", exact: true }), + ).toHaveCount(0); + expect( + ( + await api( + viewerContext.request, + baseURL, + `/api/rooms/${roomId}/members/${owner.username}`, + "PUT", + { role: "viewer" }, + ) + ).status(), + ).toBe(403); + } finally { + await viewerContext.close(); + } +}); diff --git a/examples/collaboration/tests/rich-text.spec.ts b/examples/collaboration/tests/rich-text.spec.ts index e013439..26c1b80 100644 --- a/examples/collaboration/tests/rich-text.spec.ts +++ b/examples/collaboration/tests/rich-text.spec.ts @@ -116,7 +116,8 @@ test("rich text supports lists and quotes and keeps plain text content and curso await expect(document(page).locator(".rich-caret")).toHaveCount(0); await document(page).click(); await expect(plain.locator(".cm-ySelectionInfo")).toHaveCount(0); - await expect(page.getByRole("link", { name: "Try the text editor", exact: true })).toHaveJSProperty("href", `${baseURL}/?room=${roomId}`); + await page.getByRole("button", { name: "Select example", exact: true }).click(); + await expect(page.getByRole("menuitem", { name: "Text editor", exact: true })).toHaveJSProperty("href", `${baseURL}/?room=${roomId}`); }); test("rich text viewer and revoked editor cannot edit, format, or undo; mobile fits", async ({ page, browser, baseURL }) => { diff --git a/examples/collaboration/tests/table.spec.ts b/examples/collaboration/tests/table.spec.ts index fe60470..aa8f7cf 100644 --- a/examples/collaboration/tests/table.spec.ts +++ b/examples/collaboration/tests/table.spec.ts @@ -99,7 +99,8 @@ test("table merges disconnected edits to one cell and restores data and schema a const fresh = await context.newPage(); await fresh.goto(url); await saved(fresh); await expect(display(fresh, "A1")).toHaveText(merged); await expect(fresh.getByRole("columnheader", { name: "E · Notes", exact: true })).toBeVisible(); - await fresh.getByRole("link", { name: "Try the multiplayer form", exact: true }).click(); + await fresh.getByRole("button", { name: "Select example", exact: true }).click(); + await fresh.getByRole("menuitem", { name: "Project brief", exact: true }).click(); await expect(fresh).toHaveURL(`${baseURL}/multiplayer-form.html?room=${roomId}`); await saved(fresh); await expect(fresh.getByRole("textbox", { name: "Project name", exact: true }).locator(".cm-placeholder")).toBeVisible(); }); diff --git a/examples/collaboration/tsconfig.json b/examples/collaboration/tsconfig.json index 00cb964..f10189c 100644 --- a/examples/collaboration/tsconfig.json +++ b/examples/collaboration/tsconfig.json @@ -34,7 +34,9 @@ "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", - ".next/dev/types/**/*.ts" + ".next/dev/types/**/*.ts", + ".next-browser-test/types/**/*.ts", + ".next-browser-test/dev/types/**/*.ts" ], "exclude": [ "node_modules", diff --git a/lib/synixir/room_access.ex b/lib/synixir/room_access.ex index 45a7c47..b6d3247 100644 --- a/lib/synixir/room_access.ex +++ b/lib/synixir/room_access.ex @@ -157,21 +157,34 @@ defmodule Synixir.RoomAccess do end) end + def info(room, hash) do + with_member(room, hash, "FOR SHARE", fn _id, member, _expires -> + %{ + id: room, + role: member.role, + members: Enum.map(member_roster(room), &Map.take(&1, [:username, :role])) + } + end) + end + def members(room, hash) do with_member(room, hash, "FOR SHARE", fn _id, member, _expires -> if member.role != "owner", do: Repo.rollback(:forbidden) - - Repo.all( - from m in "room_memberships", - join: u in User, - on: u.id == m.user_id, - where: m.room_id == ^room and is_nil(m.revoked_at), - order_by: u.username, - select: %{user_id: u.id, username: u.username, role: m.role} - ) + member_roster(room) end) end + defp member_roster(room) do + Repo.all( + from m in "room_memberships", + join: u in User, + on: u.id == m.user_id, + where: m.room_id == ^room and is_nil(m.revoked_at), + order_by: u.username, + select: %{user_id: u.id, username: u.username, role: m.role} + ) + end + def set_member(room, hash, username, role) when role in @roles or is_nil(role) do result = with_member(room, hash, "FOR UPDATE", fn _id, actor, _expires -> diff --git a/lib/synixir_web/controllers/room_controller.ex b/lib/synixir_web/controllers/room_controller.ex index a3d665e..d6be459 100644 --- a/lib/synixir_web/controllers/room_controller.ex +++ b/lib/synixir_web/controllers/room_controller.ex @@ -4,6 +4,7 @@ defmodule SynixirWeb.RoomController do plug :require_account def index(conn, _), do: respond(conn, RoomAccess.list_rooms(hash(conn))) + def show(conn, %{"room_id" => room}), do: respond(conn, RoomAccess.info(room, hash(conn))) def create(conn, params), do: respond(conn, RoomAccess.create_room(params["room_id"], hash(conn))) diff --git a/lib/synixir_web/router.ex b/lib/synixir_web/router.ex index 5eb5e07..f5ba746 100644 --- a/lib/synixir_web/router.ex +++ b/lib/synixir_web/router.ex @@ -30,6 +30,7 @@ defmodule SynixirWeb.Router do post "/accounts", SessionController, :register get "/rooms", RoomController, :index post "/rooms", RoomController, :create + get "/rooms/:room_id", RoomController, :show post "/rooms/:room_id/token", RoomController, :token get "/rooms/:room_id/members", RoomController, :members put "/rooms/:room_id/members/:username", RoomController, :put_member diff --git a/test/synixir/room_permissions_test.exs b/test/synixir/room_permissions_test.exs index faef7dd..054e51a 100644 --- a/test/synixir/room_permissions_test.exs +++ b/test/synixir/room_permissions_test.exs @@ -40,6 +40,27 @@ defmodule Synixir.RoomPermissionsTest do assert {:ok, _} = RoomAccess.members(ctx.room, ctx.editor.hash) end + test "room info lists members for every role but rejects outsiders and revoked members", ctx do + for actor <- [ctx.owner, ctx.editor, ctx.viewer] do + assert {:ok, %{id: room, members: members}} = RoomAccess.info(ctx.room, actor.hash) + assert room == ctx.room + assert length(members) == 3 + assert Enum.all?(members, &(Enum.sort(Map.keys(&1)) == [:role, :username])) + assert Enum.any?(members, &(&1.username == ctx.viewer.user.username)) + end + + outsider = account_fixture("outsider") + assert {:error, :unauthorized} = RoomAccess.info(ctx.room, outsider.hash) + assert {:error, :unauthorized} = RoomAccess.info("missing-room", ctx.viewer.hash) + + assert {:ok, _} = + RoomAccess.set_member(ctx.room, ctx.owner.hash, ctx.viewer.user.username, nil) + + assert {:error, :unauthorized} = RoomAccess.info(ctx.room, ctx.viewer.hash) + assert {:ok, %{members: members}} = RoomAccess.info(ctx.room, ctx.editor.hash) + assert length(members) == 2 + end + test "viewers receive sync without reverse upload and cannot save through any event", ctx do {viewer, _grant} = join_actor(ctx.room, ctx.viewer) {:ok, doc} = Documents.open(ctx.room) diff --git a/test/synixir_web/controllers/session_controller_test.exs b/test/synixir_web/controllers/session_controller_test.exs index 8aa8d9f..619a447 100644 --- a/test/synixir_web/controllers/session_controller_test.exs +++ b/test/synixir_web/controllers/session_controller_test.exs @@ -77,6 +77,7 @@ defmodule SynixirWeb.SessionControllerTest do test "anonymous requests cannot create rooms or manage memberships", %{conn: conn} do for {method, path, body} <- [ {:post, "/api/rooms", %{room_id: "anonymous"}}, + {:get, "/api/rooms/anonymous", %{}}, {:post, "/api/rooms/anonymous/token", %{user_id: "owner"}}, {:put, "/api/rooms/anonymous/members/alice", %{role: "owner"}} ] do From 6e6679ed2c6992522834644b3bd3cfbf15dd19a0 Mon Sep 17 00:00:00 2001 From: Arjay Bayona Date: Sun, 13 Sep 2026 19:04:39 +0800 Subject: [PATCH 03/12] fix(examples): remove html extensions from navigation --- README.md | 18 +++++++++--------- docs/deployment.md | 4 ++-- examples/collaboration/README.md | 8 ++++---- examples/collaboration/dev.ts | 2 +- examples/collaboration/lib/examples.ts | 14 +++++++------- examples/collaboration/tests/flowchart.spec.ts | 4 ++-- examples/collaboration/tests/kanban.spec.ts | 8 ++++---- .../tests/multiplayer-form.spec.ts | 2 +- .../tests/placeholder-cursors.spec.ts | 8 ++++---- .../collaboration/tests/playground.spec.ts | 2 +- examples/collaboration/tests/rich-text.spec.ts | 2 +- examples/collaboration/tests/sdk.spec.ts | 2 +- examples/collaboration/tests/table.spec.ts | 4 ++-- .../collaboration/tests/whiteboard-helpers.ts | 2 +- lib/synixir_web/router.ex | 9 +++++++++ scripts/staging-pilot.ts | 6 +++--- .../controllers/client_controller_test.exs | 7 +++++++ 17 files changed, 59 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index 9f8a305..5ec7fdb 100644 --- a/README.md +++ b/README.md @@ -183,16 +183,16 @@ transfers, and save tracking; account forms and editor controls stay in the app. See the [SDK interface and integration guide](packages/client/README.md) for installation, connection and save states, cancellation, cleanup, and custom -access callbacks. `/sdk.html?room=` provides a second small consumer +access callbacks. `/sdk?room=` provides a second small consumer that synchronizes a title in a Y.Map without CodeMirror. It uses the same signed-in account and room membership as the editor. ## Try the Kanban example -Open [127.0.0.1:5173/kanban.html](http://127.0.0.1:5173/kanban.html) with the same +Open [127.0.0.1:5173/kanban](http://127.0.0.1:5173/kanban) with the same development servers running. Sign in and create a board, or open an existing room. The text editor and board link to each other and share account and room access. -The production build also includes `/kanban.html`. +The production build also includes `/kanban`. - Add cards to Backlog, In progress, or Done. Open a card to edit its title, description, color, and status. Changes save as you type. @@ -234,7 +234,7 @@ npm test --workspace synixir-collaboration-example -- kanban.spec.ts ## Try the whiteboard example -Open [127.0.0.1:5173/whiteboard.html](http://127.0.0.1:5173/whiteboard.html) with the +Open [127.0.0.1:5173/whiteboard](http://127.0.0.1:5173/whiteboard) with the same development servers running. Sign in and create a room, or open an existing one. Links between the examples retain the room. Each example stores its data separately inside that room's Yjs document. @@ -263,7 +263,7 @@ migration updates. See the [Excalidraw integration notes](examples/collaboration for the scene model, asset setup, and package constraints. Offline edits remain in the open tab until reconnecting. Wait for **Saved** before -closing it. The production build includes `/whiteboard.html` and uses the same +closing it. The production build includes `/whiteboard` and uses the same room permissions, PostgreSQL persistence, and recovery flow as the other examples. `npm test` includes whiteboard concurrency and undo checks. Run its browser @@ -275,7 +275,7 @@ npm test --workspace synixir-collaboration-example -- whiteboard.spec.ts ## Try the rich-text example -Open [127.0.0.1:5173/rich-text.html](http://127.0.0.1:5173/rich-text.html) with the +Open [127.0.0.1:5173/rich-text](http://127.0.0.1:5173/rich-text) with the same development servers running. Sign in and create a room, or open an existing one. The plain-text editor remains available as its own example. @@ -310,7 +310,7 @@ npm test --workspace synixir-collaboration-example -- rich-text.spec.ts ## Try the multiplayer form example -Open [127.0.0.1:5173/multiplayer-form.html](http://127.0.0.1:5173/multiplayer-form.html) +Open [127.0.0.1:5173/multiplayer-form](http://127.0.0.1:5173/multiplayer-form) with the same development servers running. Create a room or open an existing one, then open the form in another tab to collaborate on a project brief. @@ -352,7 +352,7 @@ npm test --workspace synixir-collaboration-example -- multiplayer-form.spec.ts ## Try the flowchart builder -Open [127.0.0.1:5173/flowchart.html](http://127.0.0.1:5173/flowchart.html) with the +Open [127.0.0.1:5173/flowchart](http://127.0.0.1:5173/flowchart) with the same development servers running. Create a room or use an existing one, then open another tab to build a workflow together. @@ -396,7 +396,7 @@ npm test --workspace synixir-collaboration-example -- flowchart.spec.ts ## Try the collaborative table -Open [127.0.0.1:5173/table.html](http://127.0.0.1:5173/table.html) with the same +Open [127.0.0.1:5173/table](http://127.0.0.1:5173/table) with the same development servers running. Create a room or use an existing one, then open another tab to work on the same table. diff --git a/docs/deployment.md b/docs/deployment.md index c95cf99..694fd3d 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -20,8 +20,8 @@ elixir scripts/staging.exs up elixir scripts/staging.exs status ``` -Open [local staging](https://localhost:8443) and create an account. The SDK settings -example is at [sdk.html](https://localhost:8443/sdk.html). The generated certificate +Open [local staging](https://localhost:8443) and create an account, then try the +[SDK settings example](https://localhost:8443/sdk). The generated certificate is self-signed, so a browser requires a local certificate exception. The pilot accepts this certificate only in its browser contexts. It does not install a system trust root. For a verified command-line request: diff --git a/examples/collaboration/README.md b/examples/collaboration/README.md index 788e2c8..c89d620 100644 --- a/examples/collaboration/README.md +++ b/examples/collaboration/README.md @@ -60,10 +60,10 @@ npm run build The build compiles the SDK to `packages/client/dist` and exports Next.js pages and assets to `examples/collaboration/out`. Docker copies this export into -Phoenix's `priv/static`; the runtime needs no Node process. Existing public -URLs such as `/kanban.html?room=planning` and `/sdk.html?room=planning` are -preserved in development and production. Navigation uses full page loads to -retain the unsaved-edit warning and tear down old room connections. +Phoenix's `priv/static`; the runtime needs no Node process. Navigation uses +URLs such as `/kanban?room=planning` and `/sdk?room=planning` in development and +production. Previously shared `.html` links also work. Navigation uses full page +loads to retain the unsaved-edit warning and tear down old room connections. Browser tests run on ports 5174 and 4010 with a separate Phoenix test database. They use `.next-browser-test` so an existing development server can stay open. diff --git a/examples/collaboration/dev.ts b/examples/collaboration/dev.ts index 8171538..933dbd3 100644 --- a/examples/collaboration/dev.ts +++ b/examples/collaboration/dev.ts @@ -18,7 +18,7 @@ await app.prepare(); const handle = app.getRequestHandler(); const server = createServer((request, response) => { if (/^\/(api|socket)(\/|\?|$)/.test(request.url ?? "")) return proxy.web(request, response); - // Keep the published example URLs working in development and static exports. + // Keep previously shared .html links working in development. request.url = request.url?.replace(/^([^?]*)\.html(?=\?|$)/, "$1"); void handle(request, response); }); diff --git a/examples/collaboration/lib/examples.ts b/examples/collaboration/lib/examples.ts index 117bccd..2b7c164 100644 --- a/examples/collaboration/lib/examples.ts +++ b/examples/collaboration/lib/examples.ts @@ -19,7 +19,7 @@ export const examples = [ }, { id: "kanban", - path: "/kanban.html", + path: "/kanban", title: "Kanban board", description: "Move work forward with your whole team.", icon: Columns3, @@ -27,7 +27,7 @@ export const examples = [ }, { id: "whiteboard", - path: "/whiteboard.html", + path: "/whiteboard", title: "Whiteboard", description: "A shared canvas for your next idea.", icon: PencilRuler, @@ -35,7 +35,7 @@ export const examples = [ }, { id: "rich-text", - path: "/rich-text.html", + path: "/rich-text", title: "Rich text", description: "Turn a rough outline into something worth sharing.", icon: Type, @@ -43,7 +43,7 @@ export const examples = [ }, { id: "multiplayer-form", - path: "/multiplayer-form.html", + path: "/multiplayer-form", title: "Project brief", description: "Work through the details, together.", icon: ListChecks, @@ -51,7 +51,7 @@ export const examples = [ }, { id: "flowchart", - path: "/flowchart.html", + path: "/flowchart", title: "Flowchart", description: "Connect the dots in your team's thinking.", icon: Workflow, @@ -59,7 +59,7 @@ export const examples = [ }, { id: "table", - path: "/table.html", + path: "/table", title: "Table", description: "Give your team's information a shared home.", icon: Table2, @@ -67,7 +67,7 @@ export const examples = [ }, { id: "sdk", - path: "/sdk.html", + path: "/sdk", title: "Shared settings", description: "One small setting. Every client in sync.", icon: SlidersHorizontal, diff --git a/examples/collaboration/tests/flowchart.spec.ts b/examples/collaboration/tests/flowchart.spec.ts index 267aa9b..bc7bdea 100644 --- a/examples/collaboration/tests/flowchart.spec.ts +++ b/examples/collaboration/tests/flowchart.spec.ts @@ -7,7 +7,7 @@ async function setup(page: Page, baseURL: string|undefined) { const user = await register(page.request, baseURL); const roomId = `flowchart-${randomUUID()}`; expect((await api(page.request, baseURL, "/api/rooms", "POST", { room_id: roomId })).ok()).toBe(true); - return { user, roomId, url: `${baseURL}/flowchart.html?room=${roomId}` }; + return { user, roomId, url: `${baseURL}/flowchart?room=${roomId}` }; } const saved = (page: Page) => expect(page.locator("#save-status")).toHaveText("Saved"); const node = (page: Page, name: string) => page.getByRole("group", { name, exact: true }); @@ -109,7 +109,7 @@ test("flowchart merges offline edits, restores nodes and arrows after restart, a await expect(fresh.locator(".flow-edge-label")).toHaveText("Approved"); await fresh.getByRole("button", { name: "Select example", exact: true }).click(); await fresh.getByRole("menuitem", { name: "Whiteboard", exact: true }).click(); - await expect(fresh).toHaveURL(`${baseURL}/whiteboard.html?room=${roomId}`); await saved(fresh); + await expect(fresh).toHaveURL(`${baseURL}/whiteboard?room=${roomId}`); await saved(fresh); await expect(fresh.locator(".whiteboard-object")).toHaveCount(0); }); diff --git a/examples/collaboration/tests/kanban.spec.ts b/examples/collaboration/tests/kanban.spec.ts index 2abf71c..3b7d2b6 100644 --- a/examples/collaboration/tests/kanban.spec.ts +++ b/examples/collaboration/tests/kanban.spec.ts @@ -8,7 +8,7 @@ async function setup(page: Page, baseURL: string|undefined) { const roomId = `board-${randomUUID()}`; const response = await api(page.request, baseURL, "/api/rooms", "POST", { room_id: roomId }); expect(response.ok()).toBe(true); - return { user, roomId, url: `${baseURL}/kanban.html?room=${roomId}` }; + return { user, roomId, url: `${baseURL}/kanban?room=${roomId}` }; } const saved = (page: Page) => expect(page.locator("#save-status")).toHaveText("Saved"); const card = (page: Page, title: string) => page.getByRole("button", { name: `Open card: ${title}`, exact: true }); @@ -133,12 +133,12 @@ test("viewers can inspect cards but cannot edit, and revocation freezes an open test("board navigation stays on Kanban and the layout fits mobile", async ({ page, baseURL }, testInfo) => { await register(page.request, baseURL); - await page.goto(`${baseURL}/kanban.html`); + await page.goto(`${baseURL}/kanban`); const roomId = `navigation-${randomUUID()}`; await page.getByRole("button", { name: "Create a room", exact: true }).click(); await page.getByLabel("New room ID").fill(roomId); await page.getByRole("button", { name: "Create room", exact: true }).click(); - await expect(page).toHaveURL(`${baseURL}/kanban.html?room=${roomId}`); + await expect(page).toHaveURL(`${baseURL}/kanban?room=${roomId}`); await saved(page); await addCard(page, "Mobile planning"); await page.screenshot({ path: testInfo.outputPath("kanban-desktop.png"), fullPage: true }); @@ -154,7 +154,7 @@ test("board navigation stays on Kanban and the layout fits mobile", async ({ pag await page.keyboard.press("Escape"); await page.getByRole("button", { name: `Room details: ${roomId}` }).click(); await page.getByRole("link", { name: "All rooms" }).click(); - await expect(page).toHaveURL(`${baseURL}/kanban.html`); + await expect(page).toHaveURL(`${baseURL}/kanban`); await page.getByRole("link", { name: `${roomId} · owner`, exact: true }).click(); await saved(page); await expect(card(page, "Mobile planning")).toBeVisible(); diff --git a/examples/collaboration/tests/multiplayer-form.spec.ts b/examples/collaboration/tests/multiplayer-form.spec.ts index 1cd8a0e..7266569 100644 --- a/examples/collaboration/tests/multiplayer-form.spec.ts +++ b/examples/collaboration/tests/multiplayer-form.spec.ts @@ -15,7 +15,7 @@ async function setup(page: Page, baseURL: string|undefined) { const user = await register(page.request, baseURL); const roomId = `form-${randomUUID()}`; expect((await api(page.request, baseURL, "/api/rooms", "POST", { room_id: roomId })).ok()).toBe(true); - const url = `${baseURL}/multiplayer-form.html?room=${roomId}`; + const url = `${baseURL}/multiplayer-form?room=${roomId}`; await page.goto(url); await saved(page); return { user, roomId, url }; diff --git a/examples/collaboration/tests/placeholder-cursors.spec.ts b/examples/collaboration/tests/placeholder-cursors.spec.ts index 7954087..05c0947 100644 --- a/examples/collaboration/tests/placeholder-cursors.spec.ts +++ b/examples/collaboration/tests/placeholder-cursors.spec.ts @@ -4,9 +4,9 @@ import { api, register } from "./access-helpers.ts"; for (const [path, name] of [ ["/", "Shared document"], - ["/multiplayer-form.html", "Project name"], - ["/multiplayer-form.html", "What are we making?"], - ["/multiplayer-form.html", "Who is it for?"], + ["/multiplayer-form", "Project name"], + ["/multiplayer-form", "What are we making?"], + ["/multiplayer-form", "Who is it for?"], ]) test(`empty ${name} shows remote cursor at the start of its placeholder`, async ({ page, context, baseURL }) => { await register(page.request, baseURL); const room = `placeholder-${randomUUID()}`; @@ -46,7 +46,7 @@ test("empty rich text keeps its remote caret at the paragraph start", async ({ p await register(page.request, baseURL); const room = `rich-placeholder-${randomUUID()}`; expect((await api(page.request, baseURL, "/api/rooms", "POST", { room_id: room })).ok()).toBe(true); - const url = `${baseURL}/rich-text.html?room=${room}`; + const url = `${baseURL}/rich-text?room=${room}`; await page.goto(url); await expect(page.locator("#save-status")).toHaveText("Saved"); const source = page.getByRole("textbox", { name: "Rich text document", exact: true }); diff --git a/examples/collaboration/tests/playground.spec.ts b/examples/collaboration/tests/playground.spec.ts index 39d7467..479d698 100644 --- a/examples/collaboration/tests/playground.spec.ts +++ b/examples/collaboration/tests/playground.spec.ts @@ -53,7 +53,7 @@ test("playground menus, room tabs, connection alerts and save help work together await expect(page.getByRole("menuitem")).toHaveCount(8); await expect( page.getByRole("menuitem", { name: "Kanban board", exact: true }), - ).toHaveAttribute("href", `/kanban.html?room=${roomId}`); + ).toHaveAttribute("href", `/kanban?room=${roomId}`); await page .getByRole("menuitem", { name: "Kanban board", exact: true }) .click(); diff --git a/examples/collaboration/tests/rich-text.spec.ts b/examples/collaboration/tests/rich-text.spec.ts index 26c1b80..92c1e00 100644 --- a/examples/collaboration/tests/rich-text.spec.ts +++ b/examples/collaboration/tests/rich-text.spec.ts @@ -15,7 +15,7 @@ async function setup(page: Page, baseURL: string|undefined) { const user = await register(page.request, baseURL); const roomId = `rich-text-${randomUUID()}`; expect((await api(page.request, baseURL, "/api/rooms", "POST", { room_id: roomId })).ok()).toBe(true); - const url = `${baseURL}/rich-text.html?room=${roomId}`; + const url = `${baseURL}/rich-text?room=${roomId}`; await page.goto(url); await saved(page); return { user, roomId, url }; diff --git a/examples/collaboration/tests/sdk.spec.ts b/examples/collaboration/tests/sdk.spec.ts index 5577584..15b14ea 100644 --- a/examples/collaboration/tests/sdk.spec.ts +++ b/examples/collaboration/tests/sdk.spec.ts @@ -8,7 +8,7 @@ async function setup(page: Page, baseURL: string|undefined) { const roomId = `sdk-${randomUUID()}`; const response = await api(page.request, baseURL, "/api/rooms", "POST", { room_id: roomId }); expect(response.ok()).toBe(true); - return { user, roomId, url: `${baseURL}/sdk.html?room=${roomId}` }; + return { user, roomId, url: `${baseURL}/sdk?room=${roomId}` }; } const saved = (page: Page) => expect(page.locator("#sdk-state")).toHaveText("connected · saved"); diff --git a/examples/collaboration/tests/table.spec.ts b/examples/collaboration/tests/table.spec.ts index aa8f7cf..b6b5c8c 100644 --- a/examples/collaboration/tests/table.spec.ts +++ b/examples/collaboration/tests/table.spec.ts @@ -7,7 +7,7 @@ async function setup(page: Page, baseURL: string|undefined) { const user = await register(page.request, baseURL); const roomId = `table-${randomUUID()}`; expect((await api(page.request, baseURL, "/api/rooms", "POST", { room_id: roomId })).ok()).toBe(true); - return { user, roomId, url: `${baseURL}/table.html?room=${roomId}` }; + return { user, roomId, url: `${baseURL}/table?room=${roomId}` }; } const saved = (page: Page) => expect(page.locator("#save-status")).toHaveText("Saved"); const cell = (page: Page, address: string) => page.getByRole("gridcell", { name: new RegExp(`^${address}:`) }); @@ -101,7 +101,7 @@ test("table merges disconnected edits to one cell and restores data and schema a await expect(fresh.getByRole("columnheader", { name: "E · Notes", exact: true })).toBeVisible(); await fresh.getByRole("button", { name: "Select example", exact: true }).click(); await fresh.getByRole("menuitem", { name: "Project brief", exact: true }).click(); - await expect(fresh).toHaveURL(`${baseURL}/multiplayer-form.html?room=${roomId}`); await saved(fresh); + await expect(fresh).toHaveURL(`${baseURL}/multiplayer-form?room=${roomId}`); await saved(fresh); await expect(fresh.getByRole("textbox", { name: "Project name", exact: true }).locator(".cm-placeholder")).toBeVisible(); }); diff --git a/examples/collaboration/tests/whiteboard-helpers.ts b/examples/collaboration/tests/whiteboard-helpers.ts index ccb4995..dcbf6af 100644 --- a/examples/collaboration/tests/whiteboard-helpers.ts +++ b/examples/collaboration/tests/whiteboard-helpers.ts @@ -6,7 +6,7 @@ export async function setupWhiteboard(page: Page, baseURL: string | undefined) { const user = await register(page.request, baseURL); const roomId = `whiteboard-${randomUUID()}`; expect((await api(page.request, baseURL, "/api/rooms", "POST", { room_id: roomId })).ok()).toBe(true); - return { user, roomId, url: `${baseURL}/whiteboard.html?room=${roomId}` }; + return { user, roomId, url: `${baseURL}/whiteboard?room=${roomId}` }; } export const savedWhiteboard = (page: Page) => expect(page.locator("#save-status")).toHaveText("Saved"); export const scene = (page: Page) => page.evaluate(() => window.synixirWhiteboardTest!.getSceneElements()); diff --git a/lib/synixir_web/router.ex b/lib/synixir_web/router.ex index f5ba746..35340ef 100644 --- a/lib/synixir_web/router.ex +++ b/lib/synixir_web/router.ex @@ -2,6 +2,15 @@ defmodule SynixirWeb.Router do use SynixirWeb, :router get "/", SynixirWeb.ClientController, :index + get "/sdk", SynixirWeb.ClientController, :settings + get "/kanban", SynixirWeb.ClientController, :kanban + get "/whiteboard", SynixirWeb.ClientController, :whiteboard + get "/rich-text", SynixirWeb.ClientController, :rich_text + get "/multiplayer-form", SynixirWeb.ClientController, :multiplayer_form + get "/flowchart", SynixirWeb.ClientController, :flowchart + get "/table", SynixirWeb.ClientController, :table + + # Keep previously shared example links working. get "/sdk.html", SynixirWeb.ClientController, :settings get "/kanban.html", SynixirWeb.ClientController, :kanban get "/whiteboard.html", SynixirWeb.ClientController, :whiteboard diff --git a/scripts/staging-pilot.ts b/scripts/staging-pilot.ts index 83615ea..7b28ac6 100644 --- a/scripts/staging-pilot.ts +++ b/scripts/staging-pilot.ts @@ -146,13 +146,13 @@ try { assert.equal(denied.status(), 403); const settings = await owner.ctx.newPage(); - await navigateStaging(settings, `/sdk.html?room=${roomId}`); + await navigateStaging(settings, `/sdk?room=${roomId}`); await expect(settings.locator("#sdk-state")).toHaveText("connected · saved", { timeout: 15000 }); await settings.locator("#sdk-title").fill("Packaged SDK settings"); await expect(settings.locator("#sdk-state")).toHaveText("connected · saved"); // Exercise every exported route and its lazy-loaded editor bundle in the release. for (const route of ["kanban", "whiteboard", "rich-text", "multiplayer-form", "flowchart", "table"]) { - await navigateStaging(settings, `/${route}.html?room=${roomId}`); + await navigateStaging(settings, `/${route}?room=${roomId}`); await expect(settings.locator("#status")).toHaveText("Connected", { timeout: 15000 }); await expect(settings.locator("#editor")).toBeVisible(); } @@ -194,7 +194,7 @@ try { const owner = manifest.accounts.owner; assert.equal((await api(ctx, "/api/session", "POST", { username: owner.username, password: owner.password })).status(), 200); const settings = await ctx.newPage(); - await navigateStaging(settings, `/sdk.html?room=${manifest.roomId}`); + await navigateStaging(settings, `/sdk?room=${manifest.roomId}`); await expect(settings.locator("#sdk-state")).toHaveText("connected · saved", { timeout: 15000 }); await expect(settings.locator("#sdk-title")).toHaveValue(manifest.title); console.log("Pilot passed after replacement: sessions, passwords, permissions, document and SDK state retained"); diff --git a/test/synixir_web/controllers/client_controller_test.exs b/test/synixir_web/controllers/client_controller_test.exs index c729200..3b141db 100644 --- a/test/synixir_web/controllers/client_controller_test.exs +++ b/test/synixir_web/controllers/client_controller_test.exs @@ -3,6 +3,13 @@ defmodule SynixirWeb.ClientControllerTest do for {route, file} <- [ {"/", "index.html"}, + {"/sdk", "sdk.html"}, + {"/kanban", "kanban.html"}, + {"/whiteboard", "whiteboard.html"}, + {"/rich-text", "rich-text.html"}, + {"/multiplayer-form", "multiplayer-form.html"}, + {"/flowchart", "flowchart.html"}, + {"/table", "table.html"}, {"/sdk.html", "sdk.html"}, {"/kanban.html", "kanban.html"}, {"/whiteboard.html", "whiteboard.html"}, From a4fa7759c18b85d4b6c0c3f369d515bc9c9aba6b Mon Sep 17 00:00:00 2001 From: Arjay Bayona Date: Sun, 13 Sep 2026 19:12:26 +0800 Subject: [PATCH 04/12] refactor(examples): remove shared settings example --- README.md | 4 +- docs/deployment.md | 5 +- docs/research/example-libraries.md | 4 -- examples/collaboration/README.md | 3 +- examples/collaboration/app/globals.css | 2 - examples/collaboration/app/sdk/page.tsx | 4 -- .../components/workspace/workspace.tsx | 60 +------------------ examples/collaboration/lib/examples.ts | 9 --- .../collaboration/tests/playground.spec.ts | 2 +- examples/collaboration/tests/sdk.spec.ts | 37 ++++++++---- .../controllers/client_controller.ex | 1 - lib/synixir_web/router.ex | 2 - scripts/staging-pilot.ts | 27 ++++----- .../controllers/client_controller_test.exs | 8 ++- 14 files changed, 50 insertions(+), 118 deletions(-) delete mode 100644 examples/collaboration/app/sdk/page.tsx diff --git a/README.md b/README.md index 5ec7fdb..8ade279 100644 --- a/README.md +++ b/README.md @@ -183,9 +183,7 @@ transfers, and save tracking; account forms and editor controls stay in the app. See the [SDK interface and integration guide](packages/client/README.md) for installation, connection and save states, cancellation, cleanup, and custom -access callbacks. `/sdk?room=` provides a second small consumer -that synchronizes a title in a Y.Map without CodeMirror. It uses the same signed-in -account and room membership as the editor. +access callbacks. ## Try the Kanban example diff --git a/docs/deployment.md b/docs/deployment.md index 694fd3d..768c2b2 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -20,8 +20,7 @@ elixir scripts/staging.exs up elixir scripts/staging.exs status ``` -Open [local staging](https://localhost:8443) and create an account, then try the -[SDK settings example](https://localhost:8443/sdk). The generated certificate +Open [local staging](https://localhost:8443) and create an account. The generated certificate is self-signed, so a browser requires a local certificate exception. The pilot accepts this certificate only in its browser contexts. It does not install a system trust root. For a verified command-line request: @@ -112,7 +111,7 @@ elixir scripts/staging.exs pilot ``` The pilot creates owner, editor and viewer accounts. It checks shared editing, -read-only access, offline reconnect, the packaged SDK settings page, cookie flags, +read-only access, offline reconnect, the packaged example pages, cookie flags, origin rejection and forged proxy headers. It stops only this installation's database to check liveness 200, readiness 503 and recovery. Then it verifies a backup, replaces the app with the same image and checks saved sessions, passwords, diff --git a/docs/research/example-libraries.md b/docs/research/example-libraries.md index 60603f1..47a67e7 100644 --- a/docs/research/example-libraries.md +++ b/docs/research/example-libraries.md @@ -58,10 +58,6 @@ The local table currently binds each cell to its own Y.Text and CodeMirror insta For this small form, I would add Zod only when the schema needs to grow, and add React Hook Form when touched/error/submission state warrants it. These packages do not establish the synchronization contract. Keep Yjs as shared state, preserve the Y.Text editor bindings, and bridge local field updates and remote observations without resetting another user's active text. This recommendation follows the local [form model](../../examples/collaboration/multiplayer-form/model.ts) and [form UI](../../examples/collaboration/multiplayer-form/form.ts). -## Shared settings - -Keep the minimal React controls with `@synixir/client` and Yjs. This route exists to demonstrate the SDK; an additional state-management package would obscure that example. Local sources: [example descriptions](../../examples/collaboration/lib/examples.ts), [workspace implementation](../../examples/collaboration/components/workspace/workspace.tsx). - ## Scope of the findings These recommendations combine official documentation, published package metadata, and the current source code. No replacement package was installed or tested in the application. UI packages would still use Synixir for room access, transport, durable saves, and reconnection; integration work must preserve those behaviors. diff --git a/examples/collaboration/README.md b/examples/collaboration/README.md index c89d620..800f59b 100644 --- a/examples/collaboration/README.md +++ b/examples/collaboration/README.md @@ -43,7 +43,6 @@ the footer, alongside save status and help. | [Project brief](multiplayer-form/README.md) | React Hook Form, `@hookform/resolvers`, Zod, CodeMirror | Y.Text fields and Y.Map choices | | [Flowchart](flowchart/README.md) | `@xyflow/react` | Y.Map nodes and connections | | [Table](table/README.md) | `react-data-grid`, CodeMirror | Y.Text cells and Y.Map schema | -| Shared settings | Native controls and `@synixir/client` | Y.Map | Every example uses the same Synixir/Phoenix connection and persistence. The UI packages do not introduce another collaboration service. The text, rich-text, @@ -61,7 +60,7 @@ npm run build The build compiles the SDK to `packages/client/dist` and exports Next.js pages and assets to `examples/collaboration/out`. Docker copies this export into Phoenix's `priv/static`; the runtime needs no Node process. Navigation uses -URLs such as `/kanban?room=planning` and `/sdk?room=planning` in development and +URLs such as `/kanban?room=planning` and `/table?room=planning` in development and production. Previously shared `.html` links also work. Navigation uses full page loads to retain the unsaved-edit warning and tear down old room connections. diff --git a/examples/collaboration/app/globals.css b/examples/collaboration/app/globals.css index 201a41f..b91ad41 100644 --- a/examples/collaboration/app/globals.css +++ b/examples/collaboration/app/globals.css @@ -247,8 +247,6 @@ .editor-footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 20px; border-top: 1px solid var(--border); font-size: 11px; color: var(--muted-foreground); } #collaboration { color: var(--foreground); background: var(--card); } #collaboration .document-panel { background: var(--card); } -.sdk-page { padding: 24px; } -.sdk-page > [data-slot="card"] { max-width: 600px; margin: 24px auto; } .surface > .cm-editor { font-size: 15px; height: 100%; } .surface > .cm-editor .cm-content { padding: 28px 30px 70px; font-family: inherit; line-height: 1.8; caret-color: var(--primary); } .surface .cm-focused { outline: none; } diff --git a/examples/collaboration/app/sdk/page.tsx b/examples/collaboration/app/sdk/page.tsx deleted file mode 100644 index 293797a..0000000 --- a/examples/collaboration/app/sdk/page.tsx +++ /dev/null @@ -1,4 +0,0 @@ -import { ExampleApp } from "@/components/example-app"; -export default function Page() { - return ; -} diff --git a/examples/collaboration/components/workspace/workspace.tsx b/examples/collaboration/components/workspace/workspace.tsx index 2fd77c4..5ebbc21 100644 --- a/examples/collaboration/components/workspace/workspace.tsx +++ b/examples/collaboration/components/workspace/workspace.tsx @@ -24,13 +24,6 @@ import { } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; -import { - Card, - CardHeader, - CardTitle, - CardDescription, - CardContent, -} from "@/components/ui/card"; import { FieldGroup, Field, FieldLabel } from "@/components/ui/field"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { @@ -196,7 +189,6 @@ export function Workspace({ const [memberPending, setMemberPending] = useState(false); const [membersLoaded, setMembersLoaded] = useState(false); const [startupError, setStartupError] = useState(""); - const [title, setTitle] = useState(""); const roomRef = useRef(null); const surfaceRef = useRef(null); const frozen = @@ -210,7 +202,6 @@ export function Workspace({ let disposed = false; let stopState = () => {}; let stopPresence = () => {}; - let stopTitle = () => {}; let current: SynixirRoom | null = null; const unload = (event: BeforeUnloadEvent) => { if (current?.state.hasUnsavedChanges) { @@ -223,7 +214,6 @@ export function Workspace({ disposed = true; stopState(); stopPresence(); - stopTitle(); surfaceRef.current?.(); surfaceRef.current = null; void current?.destroy(); @@ -235,7 +225,7 @@ export function Workspace({ window.addEventListener("beforeunload", unload); window.addEventListener("pagehide", dispose); void (async () => { - const create = kind === "sdk" ? null : await loadSurface(kind); + const create = await loadSurface(kind); if (disposed) return; current = new SynixirRoom({ roomId, userId: user.id }); const room = current; @@ -246,13 +236,7 @@ export function Workspace({ color: "#3565b0", colorLight: "#3565b026", }); - surfaceRef.current = create?.(room) ?? null; - const settings = room.doc.getMap("settings"); - if (kind === "sdk") { - const changed = () => setTitle(settings.get("title") ?? ""); - settings.observe(changed); - stopTitle = () => settings.unobserve(changed); - } + surfaceRef.current = create(room); let previousRoster = ""; const presence = () => { const online = room.state.connection === "connected"; @@ -619,45 +603,7 @@ export function Workspace({ {startupError} ) : null} - {kind === "sdk" ? ( - - - - Shared settings - - - A small example of synchronized application state. - - - - - - Room title - { - if (!roomRef.current?.state.readOnly) - roomRef.current?.doc - .getMap("settings") - .set("title", event.target.value); - }} - /> - -

- {state.connection} · {state.saveStatus} - {state.error ? ` · ${state.error.code}` : ""} -

- -
-
-
- ) : ( - - )} +
diff --git a/examples/collaboration/lib/examples.ts b/examples/collaboration/lib/examples.ts index 2b7c164..3c347cf 100644 --- a/examples/collaboration/lib/examples.ts +++ b/examples/collaboration/lib/examples.ts @@ -6,7 +6,6 @@ import { Workflow, Table2, ListChecks, - SlidersHorizontal, } from "lucide-react"; export const examples = [ { @@ -65,14 +64,6 @@ export const examples = [ icon: Table2, hint: "Edit cells together or paste from a spreadsheet", }, - { - id: "sdk", - path: "/sdk", - title: "Shared settings", - description: "One small setting. Every client in sync.", - icon: SlidersHorizontal, - hint: "A minimal client SDK example", - }, ] as const; export type ExampleKind = (typeof examples)[number]["id"]; export function example(kind: ExampleKind) { diff --git a/examples/collaboration/tests/playground.spec.ts b/examples/collaboration/tests/playground.spec.ts index 479d698..2e2d1f3 100644 --- a/examples/collaboration/tests/playground.spec.ts +++ b/examples/collaboration/tests/playground.spec.ts @@ -50,7 +50,7 @@ test("playground menus, room tabs, connection alerts and save help work together await page .getByRole("button", { name: "Select example", exact: true }) .click(); - await expect(page.getByRole("menuitem")).toHaveCount(8); + await expect(page.getByRole("menuitem")).toHaveCount(7); await expect( page.getByRole("menuitem", { name: "Kanban board", exact: true }), ).toHaveAttribute("href", `/kanban?room=${roomId}`); diff --git a/examples/collaboration/tests/sdk.spec.ts b/examples/collaboration/tests/sdk.spec.ts index 15b14ea..a569c9b 100644 --- a/examples/collaboration/tests/sdk.spec.ts +++ b/examples/collaboration/tests/sdk.spec.ts @@ -8,9 +8,20 @@ async function setup(page: Page, baseURL: string|undefined) { const roomId = `sdk-${randomUUID()}`; const response = await api(page.request, baseURL, "/api/rooms", "POST", { room_id: roomId }); expect(response.ok()).toBe(true); - return { user, roomId, url: `${baseURL}/sdk?room=${roomId}` }; + return { user, roomId, url: `${baseURL}/?room=${roomId}` }; +} +// Exercise the SDK through the text example without relying on a dedicated demo page. +async function state(page: Page, saveStatus: string) { + await expect(page.locator("#status")).toHaveText("Connected"); + await expect(page.locator("#save-status")).toHaveText(saveStatus); +} +const saved = (page: Page) => state(page, "Saved"); +async function setTitle(page: Page, title: string) { + await page.evaluate(value => window.synixirTest.room!.doc.getMap("sdk-test").set("title", value), title); +} +async function expectTitle(page: Page, title: string) { + await expect.poll(() => page.evaluate(() => window.synixirTest.room!.doc.getMap("sdk-test").get("title") ?? "")).toBe(title); } -const saved = (page: Page) => expect(page.locator("#sdk-state")).toHaveText("connected · saved"); function binaryPush(message: string|number[]|Buffer|[any,any,any,any,any]) { if (!Buffer.isBuffer(message) || message[0] !== 0) return null; @@ -59,7 +70,7 @@ for (const replyTiming of ["before release", "after release"]) { }); }); await page.goto(url); - await expect(page.locator("#sdk-state")).toHaveText("connected · saving"); + await state(page, "Saving"); // Synchronization is ready while the durable save acknowledgement is held. expect(await page.evaluate(async () => window.synixirTest.room!.state.saveStatus)).toBe("saving"); const reply = JSON.parse(await replyArrived.promise); @@ -75,19 +86,19 @@ for (const replyTiming of ["before release", "after release"]) { const peer = await context.newPage(); await peer.goto(url); await saved(peer); - await page.getByLabel("Room title").fill("Shared settings"); - await expect(peer.getByLabel("Room title")).toHaveValue("Shared settings"); + await setTitle(page, "Shared value"); + await expectTitle(peer, "Shared value"); await saved(page); }); } -test("fresh grants recover a bare SDK after a crash and overlapping disconnect/connect", async ({ page, context, baseURL, backend }) => { +test("fresh grants recover the SDK after a crash and overlapping disconnect/connect", async ({ page, context, baseURL, backend }) => { let grants = 0; await page.route("**/api/rooms/*/token", async route => { grants++; await route.continue(); }); const { url } = await setup(page, baseURL); await page.goto(url); await saved(page); - await page.getByLabel("Room title").fill("Before crash"); + await setTitle(page, "Before crash"); await saved(page); const beforeRestart = grants; await backend.restart(); @@ -96,7 +107,7 @@ test("fresh grants recover a bare SDK after a crash and overlapping disconnect/c const result = await page.evaluate(async () => { const room = window.synixirTest.room!; const closing = room.disconnect(); - room.doc.getMap("settings").set("title", "Retained offline edit"); + room.doc.getMap("sdk-test").set("title", "Retained offline edit"); const first = room.connect(); const same = first === room.connect(); await Promise.all([closing, first]); @@ -107,7 +118,7 @@ test("fresh grants recover a bare SDK after a crash and overlapping disconnect/c const peer = await context.newPage(); await peer.goto(url); await saved(peer); - await expect(peer.getByLabel("Room title")).toHaveValue("Retained offline edit"); + await expectTitle(peer, "Retained offline edit"); const beforeDestroy = grants; const final = await page.evaluate(async () => { const room = window.synixirTest.room!; @@ -142,10 +153,10 @@ test("viewer transport suppresses programmatic document writes while keeping awa await page.goto(url); await saved(page); await viewer.goto(url); - await expect(viewer.locator("#sdk-state")).toHaveText("connected · view-only"); + await state(viewer, "View only"); await viewer.evaluate(async () => { const room = window.synixirTest.room!; - room.doc.getMap("settings").set("title", "local viewer mutation"); + room.doc.getMap("sdk-test").set("title", "local viewer mutation"); room.awareness.setLocalStateField("page", "viewer-presence"); }); await expect.poll(() => page.evaluate(async () => [...window.synixirTest.room!.awareness.getStates().values()] @@ -155,10 +166,10 @@ test("viewer transport suppresses programmatic document writes while keeping awa await room.disconnect(); await room.connect(); }); - await expect(viewer.locator("#sdk-state")).toHaveText("connected · view-only"); + await state(viewer, "View only"); expect(writes).toEqual([]); await page.reload(); await saved(page); - await expect(page.getByLabel("Room title")).toHaveValue(""); + await expectTitle(page, ""); } finally { await viewerContext.close(); } }); diff --git a/lib/synixir_web/controllers/client_controller.ex b/lib/synixir_web/controllers/client_controller.ex index 18cdbac..b0f7f8d 100644 --- a/lib/synixir_web/controllers/client_controller.ex +++ b/lib/synixir_web/controllers/client_controller.ex @@ -2,7 +2,6 @@ defmodule SynixirWeb.ClientController do use SynixirWeb, :controller def index(conn, _), do: page(conn, "index.html") - def settings(conn, _), do: page(conn, "sdk.html") def kanban(conn, _), do: page(conn, "kanban.html") def whiteboard(conn, _), do: page(conn, "whiteboard.html") def rich_text(conn, _), do: page(conn, "rich-text.html") diff --git a/lib/synixir_web/router.ex b/lib/synixir_web/router.ex index 35340ef..8c62ef1 100644 --- a/lib/synixir_web/router.ex +++ b/lib/synixir_web/router.ex @@ -2,7 +2,6 @@ defmodule SynixirWeb.Router do use SynixirWeb, :router get "/", SynixirWeb.ClientController, :index - get "/sdk", SynixirWeb.ClientController, :settings get "/kanban", SynixirWeb.ClientController, :kanban get "/whiteboard", SynixirWeb.ClientController, :whiteboard get "/rich-text", SynixirWeb.ClientController, :rich_text @@ -11,7 +10,6 @@ defmodule SynixirWeb.Router do get "/table", SynixirWeb.ClientController, :table # Keep previously shared example links working. - get "/sdk.html", SynixirWeb.ClientController, :settings get "/kanban.html", SynixirWeb.ClientController, :kanban get "/whiteboard.html", SynixirWeb.ClientController, :whiteboard get "/rich-text.html", SynixirWeb.ClientController, :rich_text diff --git a/scripts/staging-pilot.ts b/scripts/staging-pilot.ts index 7b28ac6..95f059e 100644 --- a/scripts/staging-pilot.ts +++ b/scripts/staging-pilot.ts @@ -15,7 +15,7 @@ const failedRequests: { path: string; error?: string|undefined; status?: number; const password = randomBytes(24).toString("hex"); type Storage = BrowserContextOptions["storageState"]; -interface Manifest { roomId: string; content: string; title: string; accounts: Record } +interface Manifest { roomId: string; content: string; accounts: Record } async function context(storageState?: Storage) { const value = await browser.newContext({ baseURL, ignoreHTTPSErrors: true, storageState }); value.on("page", page => { @@ -67,7 +67,9 @@ async function open(ctx: BrowserContext, roomId: string, role: string|RegExp|rea })); throw error; } + await page.getByRole("button", { name: `Room details: ${roomId}` }).click(); await expect(page.locator("#role-label")).toHaveText(role); + await page.getByRole("button", { name: "Close", exact: true }).click(); return page; } @@ -145,16 +147,12 @@ try { const denied = await api(viewer.ctx, `/api/rooms/${roomId}/members/${editor.username}`, "PUT", { role: "owner" }); assert.equal(denied.status(), 403); - const settings = await owner.ctx.newPage(); - await navigateStaging(settings, `/sdk?room=${roomId}`); - await expect(settings.locator("#sdk-state")).toHaveText("connected · saved", { timeout: 15000 }); - await settings.locator("#sdk-title").fill("Packaged SDK settings"); - await expect(settings.locator("#sdk-state")).toHaveText("connected · saved"); + const example = await owner.ctx.newPage(); // Exercise every exported route and its lazy-loaded editor bundle in the release. for (const route of ["kanban", "whiteboard", "rich-text", "multiplayer-form", "flowchart", "table"]) { - await navigateStaging(settings, `/${route}?room=${roomId}`); - await expect(settings.locator("#status")).toHaveText("Connected", { timeout: 15000 }); - await expect(settings.locator("#editor")).toBeVisible(); + await navigateStaging(example, `/${route}?room=${roomId}`); + await expect(example.locator("#status")).toHaveText("Connected", { timeout: 15000 }); + await expect(example.locator("#editor")).toBeVisible(); } assert.equal((await owner.ctx.request.get("/metrics")).status(), 404); const forwarded = await owner.ctx.request.get("/api/session", { @@ -163,7 +161,7 @@ try { assert.equal(await rejectsOrigin("https://untrusted.example"), 403); assert.equal(await rejectsOrigin("https://localhost"), 403, "origin port must match"); - const manifest: Manifest = { roomId, content: content + " offline", title: "Packaged SDK settings", accounts: {} }; + const manifest: Manifest = { roomId, content: content + " offline", accounts: {} }; for (const [role, person] of [["owner", owner], ["editor", editor], ["viewer", viewer]] as const) { manifest.accounts[role] = { username: person.username, password: person.password, id: person.user.id, storage: await person.ctx.storageState() }; @@ -193,11 +191,10 @@ try { const ctx = await context(); const owner = manifest.accounts.owner; assert.equal((await api(ctx, "/api/session", "POST", { username: owner.username, password: owner.password })).status(), 200); - const settings = await ctx.newPage(); - await navigateStaging(settings, `/sdk?room=${manifest.roomId}`); - await expect(settings.locator("#sdk-state")).toHaveText("connected · saved", { timeout: 15000 }); - await expect(settings.locator("#sdk-title")).toHaveValue(manifest.title); - console.log("Pilot passed after replacement: sessions, passwords, permissions, document and SDK state retained"); + const page = await open(ctx, manifest.roomId, "owner"); + await text(page, manifest.content); + await saved(page); + console.log("Pilot passed after replacement: sessions, passwords, permissions and document state retained"); } else { throw new Error("Expected seed or verify"); } diff --git a/test/synixir_web/controllers/client_controller_test.exs b/test/synixir_web/controllers/client_controller_test.exs index 3b141db..dd132e2 100644 --- a/test/synixir_web/controllers/client_controller_test.exs +++ b/test/synixir_web/controllers/client_controller_test.exs @@ -1,16 +1,20 @@ defmodule SynixirWeb.ClientControllerTest do use SynixirWeb.ConnCase, async: true + for route <- ["/sdk", "/sdk.html"] do + test "removed example #{route} returns not found", %{conn: conn} do + assert get(conn, unquote(route)).status == 404 + end + end + for {route, file} <- [ {"/", "index.html"}, - {"/sdk", "sdk.html"}, {"/kanban", "kanban.html"}, {"/whiteboard", "whiteboard.html"}, {"/rich-text", "rich-text.html"}, {"/multiplayer-form", "multiplayer-form.html"}, {"/flowchart", "flowchart.html"}, {"/table", "table.html"}, - {"/sdk.html", "sdk.html"}, {"/kanban.html", "kanban.html"}, {"/whiteboard.html", "whiteboard.html"}, {"/rich-text.html", "rich-text.html"}, From 0ea831869df147dfb84daa10f1428648d5c175a7 Mon Sep 17 00:00:00 2001 From: Arjay Bayona Date: Sun, 13 Sep 2026 19:18:31 +0800 Subject: [PATCH 05/12] style(examples): show connection status as colored dots --- examples/collaboration/app/globals.css | 7 ++++++- examples/collaboration/components/example-app.tsx | 8 ++++++-- .../collaboration/components/workspace/workspace.tsx | 12 +++++------- 3 files changed, 17 insertions(+), 10 deletions(-) diff --git a/examples/collaboration/app/globals.css b/examples/collaboration/app/globals.css index b91ad41..a9a759b 100644 --- a/examples/collaboration/app/globals.css +++ b/examples/collaboration/app/globals.css @@ -95,6 +95,7 @@ --accent: oklch(0.8778 0.1383 86.1920); --accent-foreground: oklch(0.2931 0 0); --destructive: oklch(0.5904 0.2000 26.1033); + --status-connected: oklch(0.627 0.194 149.214); --border: oklch(0.9193 0.0099 87.4740); --input: oklch(0.9193 0.0099 87.4740); --ring: oklch(0.8778 0.1383 86.1920); @@ -151,6 +152,7 @@ --accent: oklch(0.7007 0.1304 84.6350); --accent-foreground: oklch(1.0000 0 0); --destructive: oklch(0.6091 0.1931 24.9679); + --status-connected: oklch(0.723 0.219 149.579); --border: oklch(0.3001 0.0053 106.7146); --input: oklch(0.3001 0.0053 106.7146); --ring: oklch(0.7007 0.1304 84.6350); @@ -232,6 +234,9 @@ .playground-dialog { max-height: calc(100dvh - 48px); overflow-y: auto; } .member-presence { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--muted-foreground); } .status-dot { display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: var(--muted-foreground); } +.connection-dot { width: 8px; height: 8px; flex-shrink: 0; } +.connection-dot[data-state="connected"] { background: var(--status-connected); } +.connection-dot[data-state="disconnected"], .connection-dot[data-state="error"] { background: var(--destructive); } .member-presence[data-online="true"] .status-dot { background: var(--primary); } .playground-footer { display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); align-items: center; gap: 16px; min-height: 56px; color: var(--muted-foreground); font-size: 12px; } .footer-connection { display: flex; align-items: center; gap: 6px; min-width: 0; } @@ -301,4 +306,4 @@ .surface .cm-placeholder { position: absolute; inset-inline: 0; top: 0; pointer-events: none; } .rich-document ul { list-style-type: disc; } .rich-document ol { list-style-type: decimal; } -.rich-document h1, .rich-document h2, .rich-document h3 { font-weight: 600; } \ No newline at end of file +.rich-document h1, .rich-document h2, .rich-document h3 { font-weight: 600; } diff --git a/examples/collaboration/components/example-app.tsx b/examples/collaboration/components/example-app.tsx index 603c679..da7b1c3 100644 --- a/examples/collaboration/components/example-app.tsx +++ b/examples/collaboration/components/example-app.tsx @@ -1,6 +1,6 @@ "use client"; import { useEffect, useRef, useState, type FormEvent } from "react"; -import { ArrowUpRight, FolderPlus, WifiOff } from "lucide-react"; +import { ArrowUpRight, FolderPlus } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { @@ -334,7 +334,11 @@ export function ExampleApp({ kind }: { kind: ExampleKind }) {
- + Playground diff --git a/examples/collaboration/components/workspace/workspace.tsx b/examples/collaboration/components/workspace/workspace.tsx index 5ebbc21..95bd758 100644 --- a/examples/collaboration/components/workspace/workspace.tsx +++ b/examples/collaboration/components/workspace/workspace.tsx @@ -19,8 +19,6 @@ import { Undo2, Redo2, Users, - Wifi, - WifiOff, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; @@ -610,11 +608,11 @@ export function Workspace({
{rows.length} rows · {columns.length} columns
{selection ? session.address(selection.row, selection.column) : "Select a cell"}Enter or double-click to edit · Drag a header edge to resize · Paste from a spreadsheet
- { session.grid = value; }} className="shared-table rdg-light" aria-label="Shared planning table" aria-describedby="table-hint" + { session.grid = value; }} className="shared-table" aria-label="Shared planning table" aria-describedby="table-hint" columns={gridColumns} rows={rows} rowKeyGetter={rowKeyGetter} rowHeight={68} headerRowHeight={46} style={{ height: Math.min(500, Math.max(256, 47 + rows.length * 68 + 16)) }} onActivePositionChange={({ row, column }) => session.select(row && column && column.key !== "row-number" ? { row: row.id, column: column.key } : null)} diff --git a/examples/collaboration/table/table.css b/examples/collaboration/table/table.css index 165975a..ab96b56 100644 --- a/examples/collaboration/table/table.css +++ b/examples/collaboration/table/table.css @@ -10,8 +10,9 @@ --rdg-background-color: var(--card); --rdg-header-background-color: var(--muted); --rdg-border-color: var(--border); - --rdg-row-hover-background-color: var(--accent); - --rdg-row-selected-background-color: var(--accent); + --rdg-row-hover-background-color: var(--muted); + --rdg-row-selected-background-color: color-mix(in srgb, var(--ring) 15%, var(--card)); + --rdg-row-selected-hover-background-color: color-mix(in srgb, var(--ring) 25%, var(--card)); --rdg-selection-color: var(--primary); --rdg-font-size: 14px; border: 0; diff --git a/examples/collaboration/tests/themes.spec.ts b/examples/collaboration/tests/themes.spec.ts new file mode 100644 index 0000000..212cd81 --- /dev/null +++ b/examples/collaboration/tests/themes.spec.ts @@ -0,0 +1,171 @@ +import { randomUUID } from "node:crypto"; +import type { Page } from "@playwright/test"; +import { test, expect } from "./fixtures.ts"; +import { api, register } from "./access-helpers.ts"; +import { drawShape, scene } from "./whiteboard-helpers.ts"; + +test.use({ viewport: { width: 1440, height: 1000 }, colorScheme: "dark" }); + +async function toggleTheme(page: Page, theme: "light" | "dark") { + await page.getByRole("button", { name: "Account menu", exact: true }).click(); + await page.getByRole("menuitem", { name: `Use ${theme} theme`, exact: true }).click(); + await expect(page.locator("html")).toHaveCSS("color-scheme", theme); + await expect(page.getByRole("menu")).toBeHidden(); +} + +// Resolve modern CSS colors in the browser and measure the actual foreground/background pair. +async function checkSurface(page: Page, selector: string, theme: "light" | "dark", checkBackground = true) { + const { background, contrast } = await page.locator(selector).first().evaluate(element => { + const context = document.createElement("canvas").getContext("2d")!; + const paint = (color: string) => { + context.fillStyle = color; + context.fillRect(0, 0, 1, 1); + }; + const luminance = () => { + const rgb = [...context.getImageData(0, 0, 1, 1).data].slice(0, 3).map(value => { + const channel = value / 255; + return channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4; + }); + return rgb[0] * 0.2126 + rgb[1] * 0.7152 + rgb[2] * 0.0722; + }; + const surfaces = []; + for (let surface: Element | null = element; surface; surface = surface.parentElement) surfaces.unshift(getComputedStyle(surface).backgroundColor); + surfaces.forEach(paint); + const background = luminance(); + paint(getComputedStyle(element).color); + const foreground = luminance(); + return { background, contrast: (Math.max(foreground, background) + 0.05) / (Math.min(foreground, background) + 0.05) }; + }); + expect(contrast, `${selector} text contrast in ${theme} mode`).toBeGreaterThanOrEqual(4.5); + if (!checkBackground) return; + if (theme === "dark") expect(background, selector).toBeLessThan(0.15); + else expect(background, selector).toBeGreaterThan(0.5); +} + +const examples = [ + { path: "/", name: "text", surface: ".document-panel", editor: ".cm-editor" }, + { path: "/kanban", name: "kanban", surface: ".kanban-card", editor: ".kanban-board" }, + { path: "/whiteboard", name: "whiteboard", surface: ".document-panel", editor: ".excalidraw" }, + { path: "/rich-text", name: "rich-text", surface: ".format-toolbar", editor: ".tiptap" }, + { path: "/multiplayer-form", name: "form", surface: ".brief-text", editor: ".shared-form" }, + { path: "/flowchart", name: "flowchart", surface: ".flow-shape", editor: ".react-flow" }, + { path: "/table", name: "table", surface: ".shared-table", editor: ".shared-table" }, +]; + +async function populate(page: Page, name: string) { + if (name === "text") await page.getByRole("textbox", { name: "Shared document", exact: true }).fill("A shared plan, in any theme."); + if (name === "kanban") { + await page.getByRole("button", { name: "Add card to Backlog", exact: true }).click(); + await page.getByLabel("Title", { exact: true }).fill("Review the playground"); + await page.getByLabel("Description", { exact: true }).fill("Check every example in light and dark mode."); + await page.getByRole("button", { name: "Close card", exact: true }).click(); + } + if (name === "whiteboard") { await drawShape(page); await drawShape(page, "ellipse", 240); } + if (name === "rich-text") { + await page.getByRole("textbox", { name: "Rich text document", exact: true }).fill("A shared plan, in any theme."); + await page.getByLabel("Text style", { exact: true }).selectOption("1"); + await page.getByRole("button", { name: "Bold", exact: true }).click(); + } + if (name === "form") { + await page.getByRole("textbox", { name: "Project name", exact: true }).fill("Playground theme review"); + await page.getByRole("textbox", { name: "What are we making?", exact: true }).fill("A workspace that stays readable in light and dark mode."); + await page.getByRole("checkbox", { name: "Website", exact: true }).check(); + } + if (name === "flowchart") { + for (const [kind, label, color] of [["process", "Draft", "blue"], ["decision", "Ready?", "yellow"], ["terminal", "Done", "mint"], ["process", "Review", "rose"], ["process", "Archive", "white"]]) { + await page.locator(`[data-add="${kind}"]`).click(); + await page.getByLabel("Node text", { exact: true }).fill(label); + await page.getByLabel("Node color", { exact: true }).selectOption(color); + } + await page.getByRole("group", { name: "Process: Draft", exact: true }).click(); + await page.getByLabel("Connect to", { exact: true }).selectOption({ label: "Ready?" }); + await page.getByLabel("Branch label", { exact: true }).fill("Review"); + await page.getByRole("button", { name: "Connect nodes", exact: true }).click(); + await page.getByRole("button", { name: "Fit diagram", exact: true }).click(); + } + if (name === "table") { + await page.getByRole("gridcell", { name: /^A1:/ }).dblclick(); + await page.getByRole("textbox", { name: "Edit A1", exact: true }).fill("Review the playground"); + await page.keyboard.press("Escape"); + } + await expect(page.locator("#save-status")).toHaveText("Saved"); +} + +for (const example of examples) test(`${example.name} follows the playground theme without changing the document`, async ({ page, baseURL }, testInfo) => { + await register(page.request, baseURL); + const roomId = `theme-${randomUUID()}`; + expect((await api(page.request, baseURL, "/api/rooms", "POST", { room_id: roomId })).ok()).toBe(true); + await page.goto(`${baseURL}${example.path}?room=${roomId}`); + await expect(page.locator("#save-status")).toHaveText("Saved"); + await populate(page, example.name); + if (example.name === "text") { + await page.getByRole("button", { name: "Disconnect", exact: true }).click(); + await page.getByRole("textbox", { name: "Shared document", exact: true }).press("ControlOrMeta+End"); + await page.keyboard.insertText(" Keep this unsaved draft."); + await expect(page.locator("#save-status")).toHaveText("Unsaved changes"); + } + const editor = await page.locator(example.editor).elementHandle(); + const document = await page.evaluate(() => window.synixirTest.room!.doc.toJSON()); + const drawing = example.name === "whiteboard" ? await scene(page) : null; + const viewport = example.name === "flowchart" ? await page.locator(".react-flow__viewport").getAttribute("style") : null; + const updates = await page.evaluateHandle(() => { + const state = { count: 0 }; + window.synixirTest.room!.doc.on("update", () => state.count++); + return state; + }); + + for (const theme of ["light", "dark", "light", "dark"] as const) { + if (theme !== "light" || await page.locator("html").evaluate(element => element.classList.contains("dark"))) await toggleTheme(page, theme); + await checkSurface(page, example.surface, theme); + if (example.name === "kanban") { + await page.locator(".card-open").hover(); + await checkSurface(page, ".card-open", theme); + } + if (example.name === "text") { + await page.getByRole("textbox", { name: "Shared document", exact: true }).press("ControlOrMeta+a"); + await checkSurface(page, ".cm-editor.cm-focused .cm-selectionBackground", theme); + await expect(page.locator("#save-status")).toHaveText("Unsaved changes"); + } + if (example.name === "table") { + await expect(page.locator(".shared-table")).toHaveCSS("color-scheme", theme); + const row = '.shared-table [role="row"]:has(.table-cell)'; + await page.locator(row).first().hover(); + await checkSurface(page, row, theme); + } + if (example.name === "rich-text") await checkSurface(page, '.format-toolbar button[aria-pressed="true"]', theme, false); + if (example.name === "whiteboard") { + await expect.poll(() => page.evaluate(() => window.synixirWhiteboardTest!.getAppState().theme)).toBe(theme); + if (theme === "dark") await expect(page.locator(".excalidraw")).toHaveClass(/theme--dark/); + else await expect(page.locator(".excalidraw")).not.toHaveClass(/theme--dark/); + expect(await scene(page)).toEqual(drawing); + } + if (example.name === "flowchart") { + await expect(page.locator(".react-flow")).toHaveClass(new RegExp(`\\b${theme}\\b`)); + await checkSurface(page, ".react-flow", theme); + for (const color of ["blue", "yellow", "mint", "rose", "white"]) await checkSurface(page, `.color-${color} .flow-shape`, theme); + expect(await page.locator(".react-flow__viewport").getAttribute("style")).toBe(viewport); + await page.locator('.flow-connections button[aria-pressed="true"]').hover(); + await checkSurface(page, '.flow-connections button[aria-pressed="true"]', theme); + } + expect(await page.locator(example.editor).evaluate((element, original) => element === original, editor)).toBe(true); + expect(await page.evaluate(() => window.synixirTest.room!.doc.toJSON())).toEqual(document); + expect(await updates.evaluate(state => state.count)).toBe(0); + await page.locator("#editor").evaluate(element => { element.scrollTop = 0; }); + await page.screenshot({ path: testInfo.outputPath(`${example.name}-${theme}.png`), fullPage: true }); + } + + if (example.name === "text") { + await page.getByRole("button", { name: "Connect", exact: true }).click(); + await expect(page.locator("#save-status")).toHaveText("Saved"); + } + // Saved preferences must reach lazily mounted editors even when the OS disagrees. + await page.emulateMedia({ colorScheme: "light" }); + await page.reload(); + await expect(page.locator("#save-status")).toHaveText("Saved"); + await expect(page.locator("html")).toHaveCSS("color-scheme", "dark"); + await checkSurface(page, example.surface, "dark"); + if (example.name === "whiteboard") await expect.poll(() => page.evaluate(() => window.synixirWhiteboardTest!.getAppState().theme)).toBe("dark"); + if (example.name === "flowchart") await expect(page.locator(".react-flow")).toHaveClass(/\bdark\b/); + await page.setViewportSize({ width: 390, height: 844 }); + await page.screenshot({ path: testInfo.outputPath(`${example.name}-dark-mobile.png`), fullPage: true }); +}); diff --git a/examples/collaboration/whiteboard/canvas.css b/examples/collaboration/whiteboard/canvas.css index 7d4b540..b7b4505 100644 --- a/examples/collaboration/whiteboard/canvas.css +++ b/examples/collaboration/whiteboard/canvas.css @@ -1,5 +1,37 @@ .whiteboard-surface { min-width: 0; } .excalidraw-shell { position: relative; width: 100%; height: 640px; min-width: 0; } +/* Keep native drawing colors and dark-mode filtering, but match the playground controls. */ +.excalidraw-shell .excalidraw, .excalidraw-shell .excalidraw.theme--dark { + --island-bg-color: var(--card); + --input-bg-color: var(--card); + --input-border-color: var(--input); + --input-hover-bg-color: var(--muted); + --input-label-color: var(--foreground); + --popup-secondary-bg-color: var(--muted); + --popup-text-color: var(--foreground); + --popup-text-inverted-color: var(--background); + --color-on-surface: var(--foreground); + --color-surface-lowest: var(--card); + --color-surface-low: var(--muted); + --color-surface-mid: var(--muted); + --color-surface-high: var(--muted); + --color-primary: var(--primary); + --color-primary-darker: var(--primary); + --color-primary-darkest: var(--primary); + --color-primary-hover: var(--primary); + --color-primary-light: var(--accent); + --color-primary-light-darker: var(--accent); + --color-surface-primary-container: var(--accent); + --color-on-primary-container: var(--accent-foreground); + --color-brand-hover: var(--ring); + --color-brand-active: var(--ring); + --color-border-outline: var(--muted-foreground); + --color-border-outline-variant: var(--border); + --dialog-border-color: var(--border); + --focus-highlight-color: var(--ring); + --keybinding-color: var(--muted-foreground); + --link-color: var(--foreground); +} /* The workspace Undo/Redo buttons use Yjs history and keep remote edits intact. */ .excalidraw-shell .excalidraw .undo-redo-buttons { display: none; } @media (max-width: 600px) { .excalidraw-shell { height: 560px; } } diff --git a/examples/collaboration/whiteboard/excalidraw.tsx b/examples/collaboration/whiteboard/excalidraw.tsx index 009e158..12b5224 100644 --- a/examples/collaboration/whiteboard/excalidraw.tsx +++ b/examples/collaboration/whiteboard/excalidraw.tsx @@ -8,6 +8,7 @@ import type { Collaborator, ExcalidrawImperativeAPI, ExcalidrawProps, SocketId } import type { SynixirRoom } from "@synixir/client"; import { createWhiteboardModel, sameScene } from "./model.ts"; import { createCursorMotion } from "../lib/cursor-motion.ts"; +import { getTheme, subscribeTheme } from "../lib/theme.ts"; declare global { interface Window { synixirWhiteboardTest?: ExcalidrawImperativeAPI } } const cloneScene = (scene: readonly ExcalidrawElement[]): ExcalidrawElement[] => JSON.parse(JSON.stringify(scene)); @@ -173,13 +174,14 @@ export function createWhiteboard(room: SynixirRoom) { if (!disposed && !value.getAppState().isLoading) { initialized = true; paint(); collaborators(); } }); }; + // Excalidraw adapts this scene color for dark mode without changing the drawing. const initialData = { elements: [], appState: { currentItemFontFamily: 2, viewBackgroundColor: "#ffffff" } }; const uiOptions = { tools: { image: false }, canvasActions: { - loadScene: false, saveToActiveFile: false, changeViewBackgroundColor: false, + loadScene: false, saveToActiveFile: false, changeViewBackgroundColor: false, toggleTheme: false, } }; function render() { root.render(
- model.beginGesture()} onPointerUp={() => model.endGesture()} viewModeEnabled={readOnly} isCollaborating UIOptions={uiOptions} aiEnabled={false} validateEmbeddable={false} @@ -219,6 +221,7 @@ export function createWhiteboard(room: SynixirRoom) { document.addEventListener("visibilitychange", () => { if (document.hidden) clearPointer(); }, { signal: events.signal }); for (const event of ["stack-item-added", "stack-item-popped", "stack-cleared"] as const) model.history.on(event, historyState); + const stopTheme = subscribeTheme(render); render(); function destroy() { @@ -226,7 +229,7 @@ export function createWhiteboard(room: SynixirRoom) { clearTimeout(presenceTimer); cursorMotion.destroy(); if (pendingFrame !== undefined) cancelAnimationFrame(pendingFrame); - stopModel(); stopState(); + stopModel(); stopState(); stopTheme(); room.awareness.off("change", collaborators); room.awareness.setLocalStateField("whiteboard", null); events.abort(); From 78dee76227d205d0afd133419ac6652c8f643d42 Mon Sep 17 00:00:00 2001 From: Arjay Bayona Date: Sun, 13 Sep 2026 20:10:02 +0800 Subject: [PATCH 10/12] fix(examples): keep shortcut badges readable on menu highlight --- examples/collaboration/components/ui/kbd.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/collaboration/components/ui/kbd.tsx b/examples/collaboration/components/ui/kbd.tsx index 2c12278..ca8d03c 100644 --- a/examples/collaboration/components/ui/kbd.tsx +++ b/examples/collaboration/components/ui/kbd.tsx @@ -5,7 +5,7 @@ function Kbd({ className, ...props }: React.ComponentProps<"kbd">) { Date: Sun, 13 Sep 2026 22:06:11 +0800 Subject: [PATCH 11/12] style(examples): remove inline connection controls --- README.md | 5 +++-- examples/collaboration/README.md | 7 ++++--- .../components/workspace/workspace.tsx | 3 --- examples/collaboration/kanban/board.ts | 2 +- examples/collaboration/tests/access-helpers.ts | 7 +++++++ .../collaboration/tests/collaboration.spec.ts | 18 +++++++++--------- .../tests/example-shortcuts.spec.ts | 6 +++--- examples/collaboration/tests/failures.spec.ts | 12 +++++++----- examples/collaboration/tests/flowchart.spec.ts | 6 +++--- examples/collaboration/tests/kanban.spec.ts | 6 +++--- examples/collaboration/tests/lifecycle.spec.ts | 14 +++++++------- .../tests/multiplayer-form.spec.ts | 8 ++++---- .../collaboration/tests/permissions.spec.ts | 14 +++++++------- .../collaboration/tests/playground.spec.ts | 6 ++++-- examples/collaboration/tests/rich-text.spec.ts | 6 +++--- examples/collaboration/tests/table.spec.ts | 8 ++++---- examples/collaboration/tests/themes.spec.ts | 6 +++--- .../tests/whiteboard-motion.spec.ts | 3 ++- .../collaboration/tests/whiteboard.spec.ts | 6 +++--- scripts/staging-pilot.ts | 6 ++++-- 20 files changed, 81 insertions(+), 68 deletions(-) diff --git a/README.md b/README.md index 8ade279..ffc1be8 100644 --- a/README.md +++ b/README.md @@ -148,8 +148,9 @@ Create a room from **Your rooms**. Its creator becomes the first owner. 3. Owners and editors can type, select, replace, undo, and redo their own changes. Viewers receive the document and presence but cannot edit. Their status reads **View only** rather than claiming that they saved changes. -4. Disconnect an editor, make an offline edit, and reconnect. Wait for **Saved** - before closing the tab. Offline edits survive only while the tab remains open. +4. Click the connection status in the footer and choose **Disconnect**. Make an + offline edit, then open the status dialog and choose **Connect**. Wait for + **Saved** before closing the tab. Offline edits survive only while the tab remains open. 5. Restart Phoenix and open the same room to restore its saved state. 6. Owners can change roles or remove members. Affected channels close and the example requires a reload to obtain current permissions. Copy any unsaved diff --git a/examples/collaboration/README.md b/examples/collaboration/README.md index 84ab6f1..17fca44 100644 --- a/examples/collaboration/README.md +++ b/examples/collaboration/README.md @@ -16,7 +16,7 @@ server in `dev.ts` proxies `/api` and `/socket` to Phoenix at http://127.0.0.1:4000; set `SYNIXIR_ENDPOINT` to override it. The proxy preserves the browser's Origin header so Phoenix can enforce its origin allowlist. -`app/` contains the eight routes. `components/example-app.tsx` manages accounts, +`app/` contains the seven example routes. `components/example-app.tsx` manages accounts, room selection, and navigation. `components/workspace/workspace.tsx` owns each SDK connection and its subscriptions, permissions, presence, and teardown. The editor/canvas modules mount inside a React-owned host and release their @@ -29,8 +29,9 @@ the example dropdown keeps the current room when switching examples. Click the room ID for room information, the full member roster with live online status, and a second tab for creating a room. The avatar group opens access management; only owners can grant, change, or remove access. The username menu contains -theme switching and sign out. Connection details use a shadcn alert dialog in -the footer, alongside save status and help. +theme switching and sign out. Click the connection status in the footer to open +a shadcn alert dialog with connection details and Connect, Disconnect, or Reload +actions. Save status and help are on the other side of the footer. Switch examples with single letter keys: **E** for Text editor, **K** for Kanban, **W** for Whiteboard, **R** for Rich text, **F** for Form, **C** for Flowchart, diff --git a/examples/collaboration/components/workspace/workspace.tsx b/examples/collaboration/components/workspace/workspace.tsx index 95bd758..16e5fe0 100644 --- a/examples/collaboration/components/workspace/workspace.tsx +++ b/examples/collaboration/components/workspace/workspace.tsx @@ -653,9 +653,6 @@ export function Workspace({ -
Playground
diff --git a/examples/collaboration/kanban/board.ts b/examples/collaboration/kanban/board.ts index f7f9e4d..b37d646 100644 --- a/examples/collaboration/kanban/board.ts +++ b/examples/collaboration/kanban/board.ts @@ -81,7 +81,7 @@ export function createBoard(room: SynixirRoom) { publish(); const opener = [...root.querySelectorAll(".kanban-card")] .find(node => node.dataset.cardId === returnFocusId)?.querySelector(".card-open")!; - const fallback = !undo.disabled ? undo : root.querySelector(".add-card:not(:disabled)")! ?? document.querySelector("#connection")!; + const fallback = !undo.disabled ? undo : root.querySelector(".add-card:not(:disabled)")! ?? document.querySelector('button[aria-label="Connection status"]')!; (opener ?? fallback).focus(); returnFocusId = null; }); diff --git a/examples/collaboration/tests/access-helpers.ts b/examples/collaboration/tests/access-helpers.ts index 5193a2b..0271e96 100644 --- a/examples/collaboration/tests/access-helpers.ts +++ b/examples/collaboration/tests/access-helpers.ts @@ -4,6 +4,13 @@ import { randomUUID } from "node:crypto"; export const password = "a browser test password of sufficient length"; const rooms = new Map(); +export async function changeConnection(page: Page, action: "Connect" | "Disconnect" | "Reload") { + await page.getByRole("button", { name: "Connection status", exact: true }).click(); + const dialog = page.getByRole("alertdialog", { name: "Connection status", exact: true }); + await dialog.getByRole("button", { name: action, exact: true }).click(); + await expect(dialog).toBeHidden(); +} + export async function api(context: APIRequestContext, baseURL: string|undefined, path: string, method = "GET", data?: { username?: string; password?: string; room_id?: string|null; role?: any; }) { const bootstrap = await context.get(`${baseURL}/api/session`); const session = await bootstrap.json(); diff --git a/examples/collaboration/tests/collaboration.spec.ts b/examples/collaboration/tests/collaboration.spec.ts index 5369dfb..a304379 100644 --- a/examples/collaboration/tests/collaboration.spec.ts +++ b/examples/collaboration/tests/collaboration.spec.ts @@ -1,4 +1,4 @@ -import { openRoom } from "./access-helpers.ts"; +import { changeConnection, openRoom } from "./access-helpers.ts"; import { randomUUID } from "node:crypto"; import { test, expect } from "./fixtures.ts"; @@ -99,11 +99,11 @@ test("awareness shows participants and selections and removes them on departure" await alice.getByRole("button", { name: "Select example", exact: true }).focus(); await expect(bob.locator(".cm-ySelectionCaret")).toHaveCount(0); - await alice.getByRole("button", { name: "Disconnect", exact: true }).click(); + await changeConnection(alice, "Disconnect"); await expect(bob.locator("#participant-count")).toHaveText("1 online"); await expect(alice.locator("#participant-count")).toHaveText("Offline"); await expect(alice.locator("#participants li")).toHaveCount(1); - await alice.getByRole("button", { name: "Connect", exact: true }).click(); + await changeConnection(alice, "Connect"); await connected(alice); await expect(bob.locator("#participant-count")).toHaveText("2 online"); await backend.restart(); @@ -123,7 +123,7 @@ test("offline edits prompt before leaving and survive cancelling room navigation const room = `leave-${randomUUID()}`; await openRoom(page, `${baseURL}/?room=${room}`); await connected(page); - await page.getByRole("button", { name: "Disconnect", exact: true }).click(); + await changeConnection(page, "Disconnect"); await insertAtStart(page, "Keep this draft"); await expect(page.locator("#save-status")).toHaveText("Unsaved changes"); await page.getByRole("button", { name: `Room details: ${room}` }).click(); @@ -136,7 +136,7 @@ test("offline edits prompt before leaving and survive cancelling room navigation await expect(page).toHaveURL(`${baseURL}/?room=${room}`); await page.getByRole("button", { name: "Close", exact: true }).click(); await expectText(page, "Keep this draft"); - await page.getByRole("button", { name: "Connect", exact: true }).click(); + await changeConnection(page, "Connect"); await expect(page.locator("#save-status")).toHaveText("Saved"); }); @@ -162,7 +162,7 @@ test("independent clients merge concurrent text edits through Phoenix and Yex", // Both edits start from the same document state before either client sees // the other's change. Reconnection must merge them without losing text. for (const page of [alice, bob]) { - await page.getByRole("button", { name: "Disconnect", exact: true }).click(); + await changeConnection(page, "Disconnect"); await expect(page.locator("#status")).toHaveText("Disconnected"); } await insertAtStart(alice, "A "); @@ -171,7 +171,7 @@ test("independent clients merge concurrent text edits through Phoenix and Yex", await expectText(bob, "B Hello 👋"); for (const page of [alice, bob]) { - await page.getByRole("button", { name: "Connect", exact: true }).click(); + await changeConnection(page, "Connect"); await expect(page.locator("#status")).toHaveText("Connected"); } await expectText(alice, /^(A B |B A )Hello 👋$/); @@ -226,11 +226,11 @@ test("saved inserts and deletions survive a killed server without help from old await openRoom(reader, roomUrl); await expect(reader.locator("#status")).toHaveText("Connected"); await expectText(reader, "Saved after restart"); - await reader.getByRole("button", { name: "Disconnect", exact: true }).click(); + await changeConnection(reader, "Disconnect"); await insertAtStart(reader, "Offline "); await expect(reader.locator("#save-status")).toHaveText("Unsaved changes"); await backend.restart(); - await reader.getByRole("button", { name: "Connect", exact: true }).click(); + await changeConnection(reader, "Connect"); await expect(reader.locator("#status")).toHaveText("Connected"); await expect(reader.locator("#save-status")).toHaveText("Saved"); } finally { diff --git a/examples/collaboration/tests/example-shortcuts.spec.ts b/examples/collaboration/tests/example-shortcuts.spec.ts index 6b3c211..70b03c6 100644 --- a/examples/collaboration/tests/example-shortcuts.spec.ts +++ b/examples/collaboration/tests/example-shortcuts.spec.ts @@ -1,6 +1,6 @@ import { randomUUID } from "node:crypto"; import { test, expect } from "./fixtures.ts"; -import { openRoom } from "./access-helpers.ts"; +import { changeConnection, openRoom } from "./access-helpers.ts"; import { connected, documentText, expectText, insertAtStart } from "./editor-helpers.ts"; test("dropdown shortcuts switch every example and preserve the current room", async ({ page, baseURL }, testInfo) => { @@ -44,7 +44,7 @@ test("shortcuts respect editing, dialogs and the unsaved-draft warning", async ( const url = `${baseURL}/?room=${roomId}`; await openRoom(page, url); await connected(page); - await page.getByRole("button", { name: "Disconnect", exact: true }).click(); + await changeConnection(page, "Disconnect"); await insertAtStart(page, "Keep this draft"); // Shortcut letters must still type normally in the editor. @@ -75,6 +75,6 @@ test("shortcuts respect editing, dialogs and the unsaved-draft warning", async ( await shortcut; await expect(page).toHaveURL(url); await expectText(page, draft); - await page.getByRole("button", { name: "Connect", exact: true }).click(); + await changeConnection(page, "Connect"); await expect(page.locator("#save-status")).toHaveText("Saved"); }); diff --git a/examples/collaboration/tests/failures.spec.ts b/examples/collaboration/tests/failures.spec.ts index d06589f..d27f816 100644 --- a/examples/collaboration/tests/failures.spec.ts +++ b/examples/collaboration/tests/failures.spec.ts @@ -1,4 +1,4 @@ -import { openRoom } from "./access-helpers.ts"; +import { changeConnection, openRoom } from "./access-helpers.ts"; import { randomUUID } from "node:crypto"; import { test, expect } from "./fixtures.ts"; import { editor, expectText, insertAtStart, connected } from "./editor-helpers.ts"; @@ -55,9 +55,9 @@ test("missing save acknowledgements never show Saved and reconnect confirms the // A late acknowledgement after the request timed out cannot confirm it. releaseReplies(); await expect(page.locator("#save-status")).toHaveText("Save failed"); - await page.getByRole("button", { name: "Disconnect", exact: true }).click(); + await changeConnection(page, "Disconnect"); holdReplies = false; - await page.getByRole("button", { name: "Connect", exact: true }).click(); + await changeConnection(page, "Connect"); await connected(page); await expect(page.locator("#save-status")).toHaveText("Saved"); await page.reload(); @@ -87,12 +87,14 @@ test("reconnect fetches fresh access and rejected joins retain local edits", asy }); await openRoom(page, `${baseURL}/?room=access-retry-${randomUUID()}`); await expect(page.locator("#status")).toHaveText("Access expired or denied"); + await page.getByRole("button", { name: "Connection status", exact: true }).click(); await expect(page.getByRole("button", { name: "Connect", exact: true })).toBeEnabled(); + await page.getByRole("alertdialog").getByRole("button", { name: "Close", exact: true }).click(); await insertAtStart(page, "Keep my local draft"); await expect(page.locator("#save-status")).toHaveText("Unsaved changes"); expect(requests).toBe(1); reject = false; - await page.getByRole("button", { name: "Connect", exact: true }).click(); + await changeConnection(page, "Connect"); await connected(page); await expect(page.locator("#save-status")).toHaveText("Saved"); expect(requests).toBe(2); @@ -103,7 +105,7 @@ test("reconnect fetches fresh access and rejected joins retain local edits", asy await expect(page.locator("#save-status")).toHaveText("Unsaved changes"); expect(requests).toBe(3); reject = false; - await page.getByRole("button", { name: "Connect", exact: true }).click(); + await changeConnection(page, "Connect"); await connected(page); await expect(page.locator("#save-status")).toHaveText("Saved"); await page.reload(); diff --git a/examples/collaboration/tests/flowchart.spec.ts b/examples/collaboration/tests/flowchart.spec.ts index bc7bdea..2412224 100644 --- a/examples/collaboration/tests/flowchart.spec.ts +++ b/examples/collaboration/tests/flowchart.spec.ts @@ -1,6 +1,6 @@ import { randomUUID } from "node:crypto"; import { test, expect } from "./fixtures.ts"; -import { api, register } from "./access-helpers.ts"; +import { changeConnection, api, register } from "./access-helpers.ts"; import { Locator, Page } from "@playwright/test"; async function setup(page: Page, baseURL: string|undefined) { @@ -96,12 +96,12 @@ test("flowchart merges offline edits, restores nodes and arrows after restart, a await add(page, "process", "Draft"); await add(page, "terminal", "Done"); await connect(page, "Process: Draft", "Done", "Review"); const peer = await context.newPage(); await peer.goto(url); await saved(peer); - await page.getByRole("button", { name: "Disconnect", exact: true }).click(); + await changeConnection(page, "Disconnect"); await node(page, "Process: Draft").click(); await page.keyboard.press("Shift+ArrowRight"); const moved = await position(node(page, "Process: Draft")); await peer.getByRole("button", { name: "Draft → Done · Review", exact: true }).click(); await peer.getByLabel("Connection label", { exact: true }).fill("Approved"); await saved(peer); - await page.getByRole("button", { name: "Connect", exact: true }).click(); await saved(page); + await changeConnection(page, "Connect"); await saved(page); await expect(page.locator(".flow-edge-label")).toHaveText("Approved"); await page.close(); await peer.close(); await backend.restart(); const fresh = await context.newPage(); await fresh.goto(url); await saved(fresh); diff --git a/examples/collaboration/tests/kanban.spec.ts b/examples/collaboration/tests/kanban.spec.ts index 3b7d2b6..1f948cb 100644 --- a/examples/collaboration/tests/kanban.spec.ts +++ b/examples/collaboration/tests/kanban.spec.ts @@ -1,6 +1,6 @@ import { randomUUID } from "node:crypto"; import { test, expect } from "./fixtures.ts"; -import { api, register } from "./access-helpers.ts"; +import { changeConnection, api, register } from "./access-helpers.ts"; import { Page } from "@playwright/test"; async function setup(page: Page, baseURL: string|undefined) { @@ -67,7 +67,7 @@ test("offline changes converge and saved cards recover after a server crash", as await saved(peer); await addCard(page, "Review onboarding"); await expect(card(peer, "Review onboarding")).toBeVisible(); - await page.getByRole("button", { name: "Disconnect", exact: true }).click(); + await changeConnection(page, "Disconnect"); await expect(page.locator("#status")).toHaveText("Disconnected"); await card(page, "Review onboarding").click(); await page.getByLabel("Status", { exact: true }).selectOption("done"); @@ -77,7 +77,7 @@ test("offline changes converge and saved cards recover after a server crash", as await peer.getByLabel("Description").fill("Teammate edited while you were offline."); await peer.getByRole("button", { name: "Close card", exact: true }).click(); await saved(peer); - await page.getByRole("button", { name: "Connect", exact: true }).click(); + await changeConnection(page, "Connect"); await saved(page); await expect(peer.locator('[data-column="done"]')).toContainText("Review onboarding"); await expect(card(page, "Review onboarding")).toContainText("Teammate edited while you were offline."); diff --git a/examples/collaboration/tests/lifecycle.spec.ts b/examples/collaboration/tests/lifecycle.spec.ts index e223860..8aada3a 100644 --- a/examples/collaboration/tests/lifecycle.spec.ts +++ b/examples/collaboration/tests/lifecycle.spec.ts @@ -1,4 +1,4 @@ -import { openRoom } from "./access-helpers.ts"; +import { changeConnection, openRoom } from "./access-helpers.ts"; import { createHash, randomUUID } from "node:crypto"; import { test, expect } from "./fixtures.ts"; import { connected, editor } from "./editor-helpers.ts"; @@ -58,10 +58,10 @@ test("a document larger than one message survives offline edits, compaction and await page.keyboard.insertText(content); await expect(page.locator("#save-status")).toHaveText("Saved"); await expectDocument(peer, content); - await page.getByRole("button", { name: "Disconnect", exact: true }).click(); + await changeConnection(page, "Disconnect"); await edit(page, [{ from: 0, to: 1000 }, { from: content.length, insert: " offline👋" }]); await edit(peer, { from: 0, insert: "peer:" }); - await page.getByRole("button", { name: "Connect", exact: true }).click(); + await changeConnection(page, "Connect"); await connected(page); await expect(page.locator("#save-status")).toHaveText("Saved"); const expected = `peer:${content.slice(1000)} offline👋`; @@ -100,14 +100,14 @@ test("an interrupted chunk upload is never saved and reconnect retries the retai await editor(page).click(); await page.keyboard.insertText(content); await expect(page.locator("#status")).toHaveText("Reconnecting"); - await page.getByRole("button", { name: "Disconnect", exact: true }).click(); + await changeConnection(page, "Disconnect"); await expect(page.locator("#save-status")).not.toHaveText("Saved"); const reader = await context.newPage(); await openRoom(reader, url); await connected(reader); await expectDocument(reader, ""); interrupt = false; - await page.getByRole("button", { name: "Connect", exact: true }).click(); + await changeConnection(page, "Connect"); await connected(page); await expect(page.locator("#save-status")).toHaveText("Saved"); await expectDocument(reader, content); @@ -145,13 +145,13 @@ test("a missing final chunk acknowledgement stays unconfirmed and stale replies await page.keyboard.insertText(content); await expect(page.locator("#save-status")).toHaveText("Saving"); await expect(page.locator("#save-status")).toHaveText("Save failed", { timeout: 15_000 }); - await page.getByRole("button", { name: "Disconnect", exact: true }).click(); + await changeConnection(page, "Disconnect"); await edit(page, { from: content.length, insert: "new generation" }); hold = false; // Deliver the old success while offline: it cannot confirm the new edit. for (const sendHeld of release) sendHeld(); await expect(page.locator("#save-status")).not.toHaveText("Saved"); - await page.getByRole("button", { name: "Connect", exact: true }).click(); + await changeConnection(page, "Connect"); await connected(page); await expect(page.locator("#save-status")).toHaveText("Saved"); await page.reload(); diff --git a/examples/collaboration/tests/multiplayer-form.spec.ts b/examples/collaboration/tests/multiplayer-form.spec.ts index 7266569..5eafb20 100644 --- a/examples/collaboration/tests/multiplayer-form.spec.ts +++ b/examples/collaboration/tests/multiplayer-form.spec.ts @@ -1,6 +1,6 @@ import { randomUUID } from "node:crypto"; import { test, expect } from "./fixtures.ts"; -import { api, register } from "./access-helpers.ts"; +import { changeConnection, api, register } from "./access-helpers.ts"; import { Locator, Page } from "@playwright/test"; const field = (page: Page, name: string) => page.getByRole("textbox", { name, exact: true }); @@ -50,7 +50,7 @@ test("form shares fields, field presence and local undo without replacing focuse await page.getByRole("checkbox", { name: "Website", exact: true }).check(); await expect(peer.getByRole("checkbox", { name: "Website", exact: true })).toBeChecked(); await expect(field(peer, "What are we making?")).toBeFocused(); - await peer.getByRole("button", { name: "Disconnect", exact: true }).click(); + await changeConnection(peer, "Disconnect"); await expect(page.locator("#people-goal")).toHaveText(""); await saved(page); }); @@ -83,7 +83,7 @@ test("same-field offline edits and checkbox choices merge and persist after rest const peer = await context.newPage(); await peer.goto(url); await saved(peer); - await page.getByRole("button", { name: "Disconnect", exact: true }).click(); + await changeConnection(page, "Disconnect"); await expect(page.locator("#status")).toHaveText("Disconnected"); await field(page, "Project name").press("ControlOrMeta+a"); await page.keyboard.press("ArrowLeft"); @@ -95,7 +95,7 @@ test("same-field offline edits and checkbox choices merge and persist after rest await peer.keyboard.insertText(" together"); await peer.getByRole("checkbox", { name: "Email", exact: true }).check(); await saved(peer); - await page.getByRole("button", { name: "Connect", exact: true }).click(); + await changeConnection(page, "Connect"); await saved(page); await hasValue(page, "Project name", "Our Launch together"); await hasValue(peer, "Project name", "Our Launch together"); diff --git a/examples/collaboration/tests/permissions.spec.ts b/examples/collaboration/tests/permissions.spec.ts index a9b8585..a1c7440 100644 --- a/examples/collaboration/tests/permissions.spec.ts +++ b/examples/collaboration/tests/permissions.spec.ts @@ -1,6 +1,6 @@ import { randomUUID } from "node:crypto"; import { test, expect } from "./fixtures.ts"; -import { api, register, password } from "./access-helpers.ts"; +import { changeConnection, api, register, password } from "./access-helpers.ts"; import { connected, editor, expectText, insertAtStart } from "./editor-helpers.ts"; import { Page } from "@playwright/test"; @@ -47,7 +47,7 @@ test("owners manage private rooms and viewers cannot edit", async ({ page, brows await expectText(reader, "Owner's saved text"); await page.getByLabel(`Role for ${viewer.username}`).selectOption("editor"); await expect(reader.locator("#status")).toContainText("Access changed"); - await reader.getByRole("button", { name: "Reload", exact: true }).click(); + await changeConnection(reader, "Reload"); await connected(reader); await insertAtStart(reader, "Editor: "); await expect(reader.locator("#save-status")).toHaveText("Saved"); @@ -110,11 +110,11 @@ test("a role changed while disconnected preserves the draft until a fresh read-o const client = await memberContext.newPage(); await client.goto(`${baseURL}/?room=${room}`); await connected(client); - await client.getByRole("button", { name: "Disconnect", exact: true }).click(); + await changeConnection(client, "Disconnect"); await insertAtStart(client, "Offline draft: "); await expect(client.locator("#save-status")).toHaveText("Unsaved changes"); expect((await api(context.request, baseURL, path, "PUT", { role: "viewer" })).ok()).toBe(true); - await client.getByRole("button", { name: "Connect", exact: true }).click(); + await changeConnection(client, "Connect"); await expect(client.locator("#status")).toContainText("Access changed"); await expectText(client, "Offline draft: Saved document"); await editor(client).click(); @@ -125,7 +125,7 @@ test("a role changed while disconnected preserves the draft until a fresh read-o await client.keyboard.press("ControlOrMeta+Shift+Z"); await expectText(client, "Offline draft: Saved document"); client.once("dialog", dialog => dialog.accept()); - await client.getByRole("button", { name: "Reload", exact: true }).click(); + await changeConnection(client, "Reload"); await connected(client); await expect(client.locator("#save-status")).toHaveText("View only"); await expectText(client, "Saved document"); @@ -148,12 +148,12 @@ test("reconnecting after a cookie account switch cannot upload the old account's const client = await memberContext.newPage(); await client.goto(`${baseURL}/?room=${room}`); await connected(client); - await client.getByRole("button", { name: "Disconnect", exact: true }).click(); + await changeConnection(client, "Disconnect"); await insertAtStart(client, "First account's private draft: "); // Switch cookies through the API, without this page receiving a storage event. const second = await register(memberContext.request, baseURL); expect((await api(context.request, baseURL, `/api/rooms/${room}/members/${second.username}`, "PUT", { role: "editor" })).ok()).toBe(true); - await client.getByRole("button", { name: "Connect", exact: true }).click(); + await changeConnection(client, "Connect"); await expect(client.locator("#account-name")).toHaveText(second.username); await connected(client); await expectText(client, "Shared saved document"); diff --git a/examples/collaboration/tests/playground.spec.ts b/examples/collaboration/tests/playground.spec.ts index 2e2d1f3..44c40c8 100644 --- a/examples/collaboration/tests/playground.spec.ts +++ b/examples/collaboration/tests/playground.spec.ts @@ -1,6 +1,6 @@ import { randomUUID } from "node:crypto"; import { test, expect } from "./fixtures.ts"; -import { api, register } from "./access-helpers.ts"; +import { changeConnection, api, register } from "./access-helpers.ts"; import { connected, insertAtStart } from "./editor-helpers.ts"; test("playground menus, room tabs, connection alerts and save help work together", async ({ @@ -21,6 +21,7 @@ test("playground menus, room tabs, connection alerts and save help work together await page.getByLabel("New room ID").fill(roomId); await page.getByRole("button", { name: "Create room", exact: true }).click(); await connected(page); + await expect(page.locator(".footer-connection button")).toHaveCount(1); await insertAtStart(page, "A shared playground draft"); await expect(page.locator("#save-status")).toHaveText("Saved"); @@ -35,6 +36,7 @@ test("playground menus, room tabs, connection alerts and save help work together await alert.getByRole("button", { name: "Disconnect", exact: true }).click(); await expect(alert).toBeHidden(); await expect(page.locator("#status")).toHaveText("Disconnected"); + await expect(page.locator(".footer-connection button")).toHaveCount(1); await insertAtStart(page, "Offline edit. "); await page .getByRole("button", { name: "Save status and help", exact: true }) @@ -43,7 +45,7 @@ test("playground menus, room tabs, connection alerts and save help work together "Offline edits stay in this tab until you reconnect.", ); await page.getByRole("button", { name: "Close", exact: true }).click(); - await page.getByRole("button", { name: "Connect", exact: true }).click(); + await changeConnection(page, "Connect"); await connected(page); await expect(page.locator("#save-status")).toHaveText("Saved"); diff --git a/examples/collaboration/tests/rich-text.spec.ts b/examples/collaboration/tests/rich-text.spec.ts index 92c1e00..49bf3f8 100644 --- a/examples/collaboration/tests/rich-text.spec.ts +++ b/examples/collaboration/tests/rich-text.spec.ts @@ -1,6 +1,6 @@ import { randomUUID } from "node:crypto"; import { test, expect } from "./fixtures.ts"; -import { api, register } from "./access-helpers.ts"; +import { changeConnection, api, register } from "./access-helpers.ts"; import { Page } from "@playwright/test"; const document = (page: Page) => page.getByRole("textbox", { name: "Rich text document", exact: true }); @@ -65,7 +65,7 @@ test("rich text merges disconnected edits and restores formatted content after r const peer = await context.newPage(); await peer.goto(url); await saved(peer); - await page.getByRole("button", { name: "Disconnect", exact: true }).click(); + await changeConnection(page, "Disconnect"); await expect(page.locator("#status")).toHaveText("Disconnected"); await selectAll(page); await page.keyboard.press("ArrowLeft"); @@ -77,7 +77,7 @@ test("rich text merges disconnected edits and restores formatted content after r await selectAll(peer); await peer.getByRole("button", { name: "Bold", exact: true }).click(); await saved(peer); - await page.getByRole("button", { name: "Connect", exact: true }).click(); + await changeConnection(page, "Connect"); await saved(page); await expect(document(page)).toContainText("Offline: Shared sentence together."); await expect(document(peer)).toContainText("Offline: Shared sentence together."); diff --git a/examples/collaboration/tests/table.spec.ts b/examples/collaboration/tests/table.spec.ts index 01bf499..831397c 100644 --- a/examples/collaboration/tests/table.spec.ts +++ b/examples/collaboration/tests/table.spec.ts @@ -1,6 +1,6 @@ import { randomUUID } from "node:crypto"; import { test, expect } from "./fixtures.ts"; -import { api, register } from "./access-helpers.ts"; +import { changeConnection, api, register } from "./access-helpers.ts"; import { Page } from "@playwright/test"; async function setup(page: Page, baseURL: string|undefined) { @@ -86,12 +86,12 @@ test("table merges disconnected edits to one cell and restores data and schema a const { url, roomId } = await setup(page, baseURL); await page.goto(url); await saved(page); await edit(page, "A1", "Plan: "); const peer = await context.newPage(); await peer.goto(url); await saved(peer); - await page.getByRole("button", { name: "Disconnect", exact: true }).click(); + await changeConnection(page, "Disconnect"); await cell(page, "A1").dblclick(); await page.getByRole("textbox", { name: "Edit A1", exact: true }).press("End"); await page.keyboard.insertText("design "); await cell(peer, "A1").dblclick(); await peer.getByRole("textbox", { name: "Edit A1", exact: true }).press("End"); await peer.keyboard.insertText("build "); await peer.keyboard.press("Escape"); await peer.getByRole("button", { name: "+ Add column", exact: true }).click(); await peer.getByLabel("Column name", { exact: true }).fill("Notes"); await saved(peer); - await page.getByRole("button", { name: "Connect", exact: true }).click(); await saved(page); + await changeConnection(page, "Connect"); await saved(page); await expect(display(page, "A1")).toContainText("design"); await expect(display(page, "A1")).toContainText("build"); const merged = (await display(page, "A1").textContent())!; await expect(display(peer, "A1")).toHaveText(merged); @@ -168,7 +168,7 @@ test("empty table carets align with the placeholder and stay scoped to their cel await peer.keyboard.press("Escape"); await cell(peer, "B1").dblclick(); await expect(cell(peer, "B1").locator(".cm-ySelectionCaret")).toHaveCount(0); await expect(cell(peer, "A1").locator(".cell-people")).toBeVisible(); - await page.getByRole("button", { name: "Disconnect", exact: true }).click(); + await changeConnection(page, "Disconnect"); await expect(peer.locator(".cell-people:not([hidden])")).toHaveCount(0); }); diff --git a/examples/collaboration/tests/themes.spec.ts b/examples/collaboration/tests/themes.spec.ts index 212cd81..076ef46 100644 --- a/examples/collaboration/tests/themes.spec.ts +++ b/examples/collaboration/tests/themes.spec.ts @@ -1,7 +1,7 @@ import { randomUUID } from "node:crypto"; import type { Page } from "@playwright/test"; import { test, expect } from "./fixtures.ts"; -import { api, register } from "./access-helpers.ts"; +import { changeConnection, api, register } from "./access-helpers.ts"; import { drawShape, scene } from "./whiteboard-helpers.ts"; test.use({ viewport: { width: 1440, height: 1000 }, colorScheme: "dark" }); @@ -99,7 +99,7 @@ for (const example of examples) test(`${example.name} follows the playground the await expect(page.locator("#save-status")).toHaveText("Saved"); await populate(page, example.name); if (example.name === "text") { - await page.getByRole("button", { name: "Disconnect", exact: true }).click(); + await changeConnection(page, "Disconnect"); await page.getByRole("textbox", { name: "Shared document", exact: true }).press("ControlOrMeta+End"); await page.keyboard.insertText(" Keep this unsaved draft."); await expect(page.locator("#save-status")).toHaveText("Unsaved changes"); @@ -155,7 +155,7 @@ for (const example of examples) test(`${example.name} follows the playground the } if (example.name === "text") { - await page.getByRole("button", { name: "Connect", exact: true }).click(); + await changeConnection(page, "Connect"); await expect(page.locator("#save-status")).toHaveText("Saved"); } // Saved preferences must reach lazily mounted editors even when the OS disagrees. diff --git a/examples/collaboration/tests/whiteboard-motion.spec.ts b/examples/collaboration/tests/whiteboard-motion.spec.ts index 042a54c..f2e074b 100644 --- a/examples/collaboration/tests/whiteboard-motion.spec.ts +++ b/examples/collaboration/tests/whiteboard-motion.spec.ts @@ -1,3 +1,4 @@ +import { changeConnection } from "./access-helpers.ts"; import { test, expect } from "./fixtures.ts"; import { canvas, drawShape, elementPoint, savedWhiteboard as saved, scene, selectShape, setupWhiteboard as setup } from "./whiteboard-helpers.ts"; @@ -36,7 +37,7 @@ for (const reducedMotion of [false, true]) test(`Excalidraw cursor motion leaves expect(await peer.evaluate(() => JSON.stringify(window.synixirTest.room!.doc.toJSON()))).toBe(docBefore); await page.mouse.move(box!.x + 250, box!.y - 20); await expect.poll(remotePointer).toBeUndefined(); - await page.getByRole("button", { name: "Disconnect", exact: true }).click(); + await changeConnection(page, "Disconnect"); await expect.poll(() => peer.evaluate(() => window.synixirWhiteboardTest!.getAppState().collaborators.size)).toBe(0); await expect(peer.locator("#participants li")).toHaveCount(1); }); diff --git a/examples/collaboration/tests/whiteboard.spec.ts b/examples/collaboration/tests/whiteboard.spec.ts index c54f894..77411bc 100644 --- a/examples/collaboration/tests/whiteboard.spec.ts +++ b/examples/collaboration/tests/whiteboard.spec.ts @@ -1,5 +1,5 @@ import { test, expect } from "./fixtures.ts"; -import { api, register } from "./access-helpers.ts"; +import { changeConnection, api, register } from "./access-helpers.ts"; import { canvas, drawShape, elementPoint, savedWhiteboard as saved, scene, selectShape, setupWhiteboard as setup } from "./whiteboard-helpers.ts"; test.setTimeout(90_000); @@ -64,12 +64,12 @@ test("Excalidraw merges offline edits and restores saved scenes after a backend await page.goto(url); await saved(page); const element = await drawShape(page); const peer = await context.newPage(); await peer.goto(url); await saved(peer); - await page.getByRole("button", { name: "Disconnect", exact: true }).click(); + await changeConnection(page, "Disconnect"); await expect(page.locator("#status")).toHaveText("Disconnected"); await selectShape(page, element.id); await page.keyboard.press("Shift+ArrowRight"); await expect(page.locator("#save-status")).toHaveText("Unsaved changes"); await selectShape(peer, element.id); await peer.getByTestId("strokeWidth-bold").locator("..").click(); await saved(peer); - await page.getByRole("button", { name: "Connect", exact: true }).click(); await saved(page); + await changeConnection(page, "Connect"); await saved(page); await expect.poll(async () => ({ x: (await scene(page))[0].x, stroke: (await scene(page))[0].strokeWidth })) .toEqual({ x: element.x + 5, stroke: 2 }); await page.close(); await peer.close(); await backend.restart(); diff --git a/scripts/staging-pilot.ts b/scripts/staging-pilot.ts index 95f059e..250b00f 100644 --- a/scripts/staging-pilot.ts +++ b/scripts/staging-pilot.ts @@ -134,13 +134,15 @@ try { await saved(first); for (const page of [second, third]) await text(page, content); - await second.locator("#connection").click(); + await second.getByRole("button", { name: "Connection status", exact: true }).click(); + await second.getByRole("alertdialog").getByRole("button", { name: "Disconnect", exact: true }).click(); await expect(second.locator("#status")).toHaveText("Disconnected"); await second.getByRole("textbox", { name: "Shared document" }).click(); await second.keyboard.press("ControlOrMeta+End"); await second.keyboard.insertText(" offline"); await text(first, content); - await second.locator("#connection").click(); + await second.getByRole("button", { name: "Connection status", exact: true }).click(); + await second.getByRole("alertdialog").getByRole("button", { name: "Connect", exact: true }).click(); await expect(second.locator("#status")).toHaveText("Connected"); await saved(second); for (const page of [first, second, third]) await text(page, content + " offline"); From fba3c100e2f05a79603959428c0374caa99316b9 Mon Sep 17 00:00:00 2001 From: Arjay Bayona Date: Sun, 13 Sep 2026 22:07:43 +0800 Subject: [PATCH 12/12] fix(examples): retain focus in read-only rich text --- examples/collaboration/rich-text/editor.ts | 2 ++ examples/collaboration/tests/rich-text.spec.ts | 1 + 2 files changed, 3 insertions(+) diff --git a/examples/collaboration/rich-text/editor.ts b/examples/collaboration/rich-text/editor.ts index f9959ee..bb4e57d 100644 --- a/examples/collaboration/rich-text/editor.ts +++ b/examples/collaboration/rich-text/editor.ts @@ -106,6 +106,8 @@ export function createRichText(room: SynixirRoom) { ], editorProps: { attributes: { "aria-label": "Rich text document", "aria-describedby": "editor-help", "aria-multiline": "true", role: "textbox", spellcheck: "true", + // Keep key events inside the editor when it becomes read-only. + tabindex: "0", } }, }); diff --git a/examples/collaboration/tests/rich-text.spec.ts b/examples/collaboration/tests/rich-text.spec.ts index 49bf3f8..103184c 100644 --- a/examples/collaboration/tests/rich-text.spec.ts +++ b/examples/collaboration/tests/rich-text.spec.ts @@ -145,6 +145,7 @@ test("rich text viewer and revoked editor cannot edit, format, or undo; mobile f await expect(document(viewer)).not.toBeEditable(); await expect(viewer.getByRole("button", { name: "Undo", exact: true })).toBeDisabled(); await selectAll(viewer); + await expect(document(viewer)).toBeFocused(); await viewer.keyboard.press("ControlOrMeta+b"); await viewer.keyboard.press("ControlOrMeta+z"); await viewer.keyboard.type("cannot write");