improved a lot of things

This commit is contained in:
SinachPat
2026-04-26 09:09:55 +01:00
parent 2911f22df8
commit 97315846be
70 changed files with 4967 additions and 10 deletions
+89
View File
@@ -0,0 +1,89 @@
import type { ArtboardStorageObject, UserPresence } from './room-schema.js';
// ── MultiplayerAdapter interface ──────────────────────────────────────────────
// The single contract that both the Zustand (Phase 1) and Liveblocks (Phase 3)
// implementations must satisfy. Components import this interface, never a
// concrete store, so the swap is a one-line dependency injection change.
export interface MultiplayerAdapter {
// ── Presence ────────────────────────────────────────────────────────────────
/** Returns the calling user's own presence object. */
getSelfPresence(): UserPresence | null;
/** Returns presence objects for all other users in the room. */
getOthersPresence(): readonly UserPresence[];
/**
* Update fields of the calling user's own presence.
* Each field is optional (omit to leave unchanged). Under exactOptionalPropertyTypes,
* Partial<> is not used here because it conflates "key absent" with "key = undefined"
* for non-nullable fields like cursor. Use null to explicitly clear cursor position.
*/
updatePresence(patch: {
cursor?: { x: number; y: number } | null;
activeArtboardId?: string | null;
selectedComponentIds?: readonly string[];
}): void;
// ── Artboard storage ─────────────────────────────────────────────────────────
/** Returns the current storage object for an artboard, or null if not found. */
getArtboard(artboardId: string): ArtboardStorageObject | null;
/** Returns all artboard storage objects in the workspace. */
getAllArtboards(): readonly ArtboardStorageObject[];
/** Returns the artboard display order (array of IDs). */
getArtboardOrder(): readonly string[];
/** Upserts an artboard storage object. Creates it if it doesn't exist. */
upsertArtboard(artboard: ArtboardStorageObject): void;
/** Reorders artboards by providing the new full ordering. */
setArtboardOrder(order: readonly string[]): void;
/** Locks an artboard so other users cannot make edits. */
lockArtboard(artboardId: string): void;
/** Unlocks an artboard. */
unlockArtboard(artboardId: string): void;
// ── Connection state ─────────────────────────────────────────────────────────
/** Whether the adapter is connected to a live room or operating locally. */
isConnected(): boolean;
/**
* Subscribe to state changes. Returns an unsubscribe function.
* Implementation must call the callback on every presence or storage mutation.
*/
subscribe(callback: () => void): () => void;
}
// ── Phase 1 stub implementation ───────────────────────────────────────────────
// Used during Phase 1 and 2 development (no Liveblocks dependency).
// All reads return empty/null; writes are no-ops.
// Phase 3 replaces this with LiveblocksAdapter from packages/app/src/lib/liveblocks.ts.
export function createLocalAdapter(): MultiplayerAdapter {
const listeners = new Set<() => void>();
return {
getSelfPresence: () => null,
getOthersPresence: () => [],
updatePresence: () => undefined,
getArtboard: () => null,
getAllArtboards: () => [],
getArtboardOrder: () => [],
upsertArtboard: () => undefined,
setArtboardOrder: () => undefined,
lockArtboard: () => undefined,
unlockArtboard: () => undefined,
isConnected: () => false,
subscribe(cb) {
listeners.add(cb);
return () => { listeners.delete(cb); };
},
};
}
+30
View File
@@ -0,0 +1,30 @@
// ── Cursor colour palette ─────────────────────────────────────────────────────
// 12-colour WCAG AA-passing palette for presence cursors.
// Colours are assigned deterministically by user ID so they're stable across reconnects.
const PALETTE = [
'#E03D3D', // red
'#E07B3D', // orange
'#D4A017', // amber
'#4CAF50', // green
'#2196F3', // blue
'#9C27B0', // purple
'#00BCD4', // cyan
'#FF4081', // pink
'#8BC34A', // lime
'#FF5722', // deep-orange
'#3F51B5', // indigo
'#009688', // teal
] as const;
/** Assigns a cursor colour to a user ID deterministically. */
export function cursorColorForUser(userId: string): string {
let hash = 0;
for (let i = 0; i < userId.length; i++) {
hash = (hash * 31 + userId.charCodeAt(i)) >>> 0;
}
const color = PALETTE[hash % PALETTE.length];
return color ?? PALETTE[0];
}
export { PALETTE as CURSOR_PALETTE };
+14
View File
@@ -0,0 +1,14 @@
export type {
UserPresence,
InitialPresence,
ArtboardStorageObject,
WorkspaceStorage,
LiveblocksRoomTypes,
RoomEvent,
} from './room-schema.js';
export { UserPresenceSchema, workspaceRoomId } from './room-schema.js';
export type { MultiplayerAdapter } from './adapter.js';
export { createLocalAdapter } from './adapter.js';
export { cursorColorForUser, CURSOR_PALETTE } from './cursor-colors.js';
+93
View File
@@ -0,0 +1,93 @@
// ── Liveblocks Room Schema ────────────────────────────────────────────────────
// Defines the TypeScript shapes for a Liveblocks room.
// One room = one Workspace. Each artboard is a LiveObject in Storage.
//
// IMPORTANT: Liveblocks is a Phase 3 dependency. This file defines types only.
// Do NOT import @liveblocks/client here — the peer dep is optional until Phase 3.
import { z } from 'zod';
// ── Presence ──────────────────────────────────────────────────────────────────
// Updated at ≤50ms throttle as the user moves the cursor or changes selection.
export interface UserPresence {
/** Liveblocks user ID (from Clerk JWT sub) */
userId: string;
displayName: string;
/** Hex colour assigned to this user's cursor */
cursorColor: string;
/** Canvas-space coordinates of the user's cursor, or null if off-canvas */
cursor: { x: number; y: number } | null;
/** Currently focused artboard ID */
activeArtboardId: string | null;
/** IDs of currently selected components (in the active artboard) */
selectedComponentIds: readonly string[];
}
export type InitialPresence = Omit<UserPresence, 'userId' | 'displayName' | 'cursorColor'>;
// ── Storage ───────────────────────────────────────────────────────────────────
// CRDT-backed shared state. Mutations are conflict-free via Liveblocks LiveObject/LiveMap.
/** Serializable artboard state stored as a Liveblocks LiveObject. */
export interface ArtboardStorageObject {
id: string;
name: string;
/** Viewport transform: scale + translate */
viewport: { scale: number; translateX: number; translateY: number };
/** Locked artboards cannot be edited by collaborators */
locked: boolean;
/** ISO timestamp of the last write to this artboard's storage object */
lastModifiedAt: string;
lastModifiedByUserId: string;
}
/** Top-level Liveblocks Storage shape for a Workspace room. */
export interface WorkspaceStorage {
/** LiveMap<artboardId, ArtboardStorageObject> — one entry per artboard */
artboards: Record<string, ArtboardStorageObject>;
/** Ordering of artboard IDs in the navigator panel (drag-to-reorder) */
artboardOrder: readonly string[];
}
// ── Liveblocks room config types ──────────────────────────────────────────────
// These shapes mirror the generic parameters expected by createRoomContext.
// Used as TypeScript contracts; actual createClient / createRoomContext calls
// live in the Phase 3 integration (packages/app/src/lib/liveblocks.ts).
export interface LiveblocksRoomTypes {
Presence: UserPresence;
Storage: WorkspaceStorage;
UserMeta: {
id: string;
info: { name: string; avatar?: string };
};
RoomEvent: RoomEvent;
}
// ── Room events ───────────────────────────────────────────────────────────────
// Broadcast events sent between users without CRDT persistence.
export type RoomEvent =
| { type: 'ARTBOARD_LOCKED'; artboardId: string; byUserId: string }
| { type: 'ARTBOARD_UNLOCKED'; artboardId: string; byUserId: string }
| { type: 'DIFF_EXPORTED'; diffId: string; byUserId: string }
| { type: 'COMPLETION_ZONE_ACCEPTED'; zoneId: string; artboardId: string };
// ── Room ID convention ────────────────────────────────────────────────────────
/** Derives the Liveblocks room ID from a workspace UUID. */
export function workspaceRoomId(workspaceId: string): string {
return `workspace:${workspaceId}`;
}
// ── Zod schema for presence validation ───────────────────────────────────────
export const UserPresenceSchema = z.object({
userId: z.string(),
displayName: z.string(),
cursorColor: z.string().regex(/^#[0-9a-f]{6}$/i),
cursor: z.object({ x: z.number(), y: z.number() }).nullable(),
activeArtboardId: z.string().nullable(),
selectedComponentIds: z.array(z.string()),
});