react type declarations

The precise contract for @playspace/react.

/**
 * @playspace/react — PlaySpace surfaces as React components
 *
 * Every component here is role-aware. It reads the role from the token in
 * context and renders the clinician's side or the client's side accordingly.
 * You never pass a role, and there is no prop that would let you pass the wrong
 * one.
 *
 * React 18 and 19. Every component is a client component.
 *
 * @packageDocumentation
 */

// `JSX` is imported rather than assumed global: React 19's types removed the
// global JSX namespace, so a declaration file relying on it fails to compile
// against React 19 while compiling fine against React 18.
import type { ReactNode, CSSProperties, JSX } from 'react'

// ─── Shared vocabulary ───────────────────────────────────────────────────────

export type Surface =
  | 'sandtray'
  | 'dollhouse'
  | 'whiteboard'
  | 'games'
  | 'playroom'
  | 'storybooks'
  | 'worksheets'
  | 'forms'
  | 'studio'
  | 'models'

export type Role = 'clinician' | 'patient'

export type PlayroomType = 'child' | 'teen' | 'adult'

export type SessionItem =
  | 'activityShelf'
  | 'multiplayerGames'
  | 'singlePlayerGames'
  | 'wallPosters'
  | 'whiteboard'
  | 'sandTray'
  | 'dollhouse'

export type ArtifactType =
  | 'storybook'
  | 'storybookCopy'
  | 'worksheet'
  | 'worksheetCopy'
  | 'form'
  | 'formResponse'
  | 'sandtraySave'
  | 'dollhouseSave'
  | 'whiteboardSnapshot'
  | 'generatedGame'
  | 'model'
  | 'sessionSummary'

/** A reference to a person by either identifier. Supply exactly one. */
export type Ref = { id: string } | { externalId: string }

export interface Reference {
  id: string
  externalId: string | null
}

// ─── Provider ────────────────────────────────────────────────────────────────

export interface PlaySpaceTheme {
  /** Applied to chrome, controls and focus rings. Not to play content. */
  accent?: string
  radius?: 'none' | 'sm' | 'md' | 'lg'
  /** `'inherit'` adopts the host page's font stack. */
  font?: 'inherit' | string
  colorScheme?: 'light' | 'dark' | 'system'
}

export interface PlaySpaceProviderProps {
  /**
   * Called on mount and again at eighty percent of the current token's lifetime.
   * You never write expiry handling; a surface mid-interaction is not torn down
   * by a renewal.
   */
  fetchToken?: () => Promise<string>
  /**
   * A static token. Use instead of `fetchToken` for short-lived contexts only —
   * it will not renew, and a clinician who leaves the tab open will be stranded.
   */
  token?: string
  onEvent?: (event: PlaySpaceEvent) => void
  /** Must be idempotent — an error may fire more than once for one cause. */
  onError?: (error: PlaySpaceEmbedError) => void
  theme?: PlaySpaceTheme
  /** Interface language. Falls back to the clinician's account setting, then `'en'`. */
  locale?: string
  /** Inferred from the token. Set explicitly only to assert. */
  environment?: 'production' | 'sandbox'
  children: ReactNode
}

/**
 * The root. Everything else must be inside one.
 *
 * One provider per session — do not nest, and do not mount two for the same
 * session. If you need surfaces in two places on a page, put the provider above
 * both.
 */
export declare function PlaySpaceProvider(props: PlaySpaceProviderProps): JSX.Element

// ─── Common surface props ────────────────────────────────────────────────────

export interface SurfaceProps {
  /**
   * The surface owns its own height. Set width and an optional max-height on the
   * container instead; a fixed container height clips.
   */
  height?: number | string
  className?: string
  style?: CSSProperties
  /** Rendered while the surface is establishing. Defaults to our own skeleton. */
  fallback?: ReactNode
}

export interface Snapshot {
  artifactId: string
  type: ArtifactType
  createdAt: string
}

// ─── Live play surfaces ──────────────────────────────────────────────────────

export interface SandtrayProps extends SurfaceProps {
  /** Resume a saved tray. Omit for a fresh one. */
  saveId?: string
  /** Persists on change, debounced. Clinician token only. Default `true`. */
  autosave?: boolean
  /** Restrict the figure library, e.g. `['core', 'animals', 'family']`. */
  figureSets?: string[]
  onSnapshot?: (snapshot: Snapshot) => void
}

/**
 * The flagship surface. A three-dimensional tray, a categorised figure library,
 * and every participant's cursor.
 *
 * A clinician gets the library, sand tools, camera controls, save and reset, and
 * the ability to hand control to the client. A client gets the tray, their
 * cursor, and the figures the clinician has made available.
 *
 * Needs 640 by 480 to be usable; below that it renders a message asking the
 * person to enlarge the window rather than an unplayable tray.
 */
export declare function Sandtray(props: SandtrayProps): JSX.Element

export interface DollhouseProps extends SandtrayProps {
  layout?: 'house' | 'apartment' | 'classroom'
  rooms?: string[]
}

export declare function Dollhouse(props: DollhouseProps): JSX.Element

export type WhiteboardTool = 'pen' | 'highlighter' | 'shapes' | 'text' | 'stamps' | 'eraser'

export interface WhiteboardProps extends SurfaceProps {
  /** An image reference puts a worksheet or photograph under the drawing layer. */
  background?: 'blank' | 'grid' | 'lined' | { imageUrl: string }
  tools?: WhiteboardTool[]
  onSnapshot?: (snapshot: Snapshot) => void
}

/**
 * Collaborative drawing. A clinician can clear the board, change the background,
 * and restrict the client's tools live. A client draws.
 */
export declare function Whiteboard(props: WhiteboardProps): JSX.Element

export interface GameSummary {
  slug: string
  title: string
  minPlayers: number
  maxPlayers: number
  skills: string[]
  thumbnailUrl: string | null
}

export interface GameLibraryProps extends SurfaceProps {
  filter?: {
    players?: number
    skills?: string[]
    ageRange?: [number, number]
    category?: string
  }
  onLaunch?: (game: GameSummary) => void
}

/**
 * The licensed catalog, filterable.
 *
 * Clinician-only. A patient token renders nothing and emits `error` with
 * `role_not_permitted` — branch on `role` from `usePlaySpace()` to avoid it.
 */
export declare function GameLibrary(props: GameLibraryProps): JSX.Element

export interface GameProps extends SurfaceProps {
  slug: string
}

export declare function Game(props: GameProps): JSX.Element

export interface PlayroomProps extends SurfaceProps {
  /** Defaults to the session's playroom type. */
  type?: PlayroomType
  theme?: string
  items?: SessionItem[]
}

/**
 * Every live surface in one themed environment, with the clinician steering and
 * the client following. When the clinician opens the dollhouse, the client's
 * view follows.
 */
export declare function Playroom(props: PlayroomProps): JSX.Element

/**
 * `Playroom` plus the session frame — video panel, participant presence, the
 * waiting-to-join notice, and session controls. The complete hosted experience,
 * inside your application.
 */
export declare function PlayroomSession(props: PlayroomProps): JSX.Element

// ─── Content surfaces ────────────────────────────────────────────────────────

export interface StorybookCreatorProps extends SurfaceProps {
  /** Set it and the storybook is personal. Omit it and it is library content. */
  subject?: Ref
  /** Fires the moment the row exists, BEFORE any page is written. */
  onCreated?: (event: { storybookId: string }) => void
  /** Fires when generation finished and the book is readable. */
  onReady?: (event: { storybookId: string; title: string | null; pageCount: number }) => void
}

/**
 * `onCreated` firing before any page is written is deliberate: a host can record
 * the identifier even if the clinician closes the panel mid-generation.
 */
export declare function StorybookCreator(props: StorybookCreatorProps): JSX.Element

export interface StorybookReaderProps extends SurfaceProps {
  storybookId: string
  /** Controlled paging, if you want your own navigation. */
  page?: number
  onPageChange?: (page: number) => void
}

/** A client's reading position persists across sessions. */
export declare function StorybookReader(props: StorybookReaderProps): JSX.Element

export interface StorybookLibraryProps extends SurfaceProps {
  filter?: { subject?: Ref; createdBy?: Ref }
  allowCreate?: boolean
  onSelect?: (storybook: { id: string; title: string | null }) => void
}

export declare function StorybookLibrary(props: StorybookLibraryProps): JSX.Element

export interface WorksheetCanvasProps extends SurfaceProps {
  worksheetId: string
  /** `'collaborative'` requires a live session. `'review'` is read-only for both roles. */
  mode?: 'collaborative' | 'solo' | 'review'
  subject?: Ref
  page?: number
  onPageChange?: (page: number) => void
  onComplete?: (event: { copyId: string }) => void
}

/**
 * A clinician can annotate, add pages and mark complete. A client fills in.
 */
export declare function WorksheetCanvas(props: WorksheetCanvasProps): JSX.Element

export interface WorksheetLibraryProps extends SurfaceProps {
  filter?: { subject?: Ref; createdBy?: Ref }
  allowCreate?: boolean
  onSelect?: (worksheet: { id: string; title: string }) => void
}

export declare function WorksheetLibrary(props: WorksheetLibraryProps): JSX.Element

export interface FormFillProps extends SurfaceProps {
  formId: string
  subject?: Ref
  /**
   * Carries the response identifier and, for scored instruments, the score. The
   * answers themselves are fetched with the server SDK — they are never handed to
   * a browser you do not control.
   */
  onSubmit?: (event: { responseId: string; score: number | null }) => void
}

export declare function FormFill(props: FormFillProps): JSX.Element

export interface FormBuilderProps extends SurfaceProps {
  formId?: string
  onSaved?: (event: { formId: string }) => void
}

/** Clinician-only. Emits `role_not_permitted` on a patient token. */
export declare function FormBuilder(props: FormBuilderProps): JSX.Element

// ─── Generation surfaces ─────────────────────────────────────────────────────

export interface PlayStudioProps extends SurfaceProps {
  subject?: Ref
  onGenerated?: (event: { gameId: string; playUrl: string }) => void
}

/** A clinician describes a game and gets a playable one. Renders its own progress state. */
export declare function PlayStudio(props: PlayStudioProps): JSX.Element

export interface ModelStudioProps extends SurfaceProps {
  source?: 'text' | 'image'
  onGenerated?: (event: { modelId: string; previewUrl: string | null }) => void
}

/**
 * A figure that is not in the sandtray library, generated from a description or a
 * photograph. The photograph is discarded after generation.
 */
export declare function ModelStudio(props: ModelStudioProps): JSX.Element

export interface ContentStudioProps extends SurfaceProps {
  subject?: Ref
  /** Restrict the tabs. Defaults to every licensed generation surface. */
  tabs?: Array<'storybook' | 'worksheet' | 'form' | 'game' | 'model'>
  onGenerated?: (event: { artifactId: string; type: ArtifactType }) => void
}

/** Every generation surface behind one tabbed interface. */
export declare function ContentStudio(props: ContentStudioProps): JSX.Element

// ─── Hooks ───────────────────────────────────────────────────────────────────

export interface PlaySpaceContextValue {
  ready: boolean
  role: Role | null
  subject: Reference | null
  session: {
    id: string
    externalId: string | null
    status: 'pending' | 'live' | 'complete' | 'cancelled'
    participants: Array<{ role: Role; present: boolean }>
  } | null
  /** What this token permits. Render your own navigation from it. */
  surfaces: Surface[]
  /** Clinician only. A patient token throws. */
  open: (surface: Surface) => void
  close: (surface: Surface) => void
  end: () => Promise<void>
}

export declare function usePlaySpace(): PlaySpaceContextValue

export interface ArtifactSummary {
  id: string
  type: ArtifactType
  title: string | null
  createdBy: Reference
  subject: Reference | null
  createdAt: string
}

export declare function useArtifacts(filter?: {
  subject?: Ref
  createdBy?: Ref
  type?: ArtifactType
}): {
  artifacts: ArtifactSummary[]
  isLoading: boolean
  error: PlaySpaceEmbedError | null
  refresh: () => Promise<void>
}

export declare function useGenerationJob(jobId: string | null): {
  job: { id: string; status: string; progress: number; reason: string | null } | null
  isLoading: boolean
}

export declare function useUsage(): {
  usage: {
    period: string
    resources: Record<string, { used: number; limit: number }>
    resetsAt: string
  } | null
  isLoading: boolean
}

// ─── Events ──────────────────────────────────────────────────────────────────

/**
 * At-most-once, and never carrying clinical content — identifiers, counts,
 * durations and enumerated values only.
 */
export interface PlaySpaceEventMap {
  /** The surface mounted and is interactive. */
  ready: { role: Role; surfaces: Surface[] }
  'surface.opened': { surface: Surface }
  'surface.closed': { surface: Surface; durationMs: number }
  'participant.joined': { role: Role }
  'participant.left': { role: Role }
  /** Something now exists. It may not be finished. */
  'artifact.created': { artifactId: string; type: ArtifactType }
  /** It is finished and usable. */
  'artifact.ready': { artifactId: string; type: ArtifactType }
  'job.progress': { jobId: string; percent: number }
  'session.ended': { durationMs: number; surfacesUsed: Surface[] }
}

export type PlaySpaceEventType = keyof PlaySpaceEventMap

export type PlaySpaceEvent = {
  [K in PlaySpaceEventType]: { type: K; payload: PlaySpaceEventMap[K] }
}[PlaySpaceEventType]

// ─── Errors ──────────────────────────────────────────────────────────────────

/**
 * A fixed taxonomy, so a host can tell our failure from its own and from the
 * browser's.
 */
export type PlaySpaceErrorCode =
  /** The token lapsed and renewal did not run. Usually a static `token` prop. */
  | 'token_expired'
  /** Malformed, revoked, or issued for another organisation. */
  | 'token_invalid'
  /** `fetchToken` threw three times. Your token endpoint is down. */
  | 'token_refresh_failed'
  /** This page's origin is not on the token. Add it at mint. */
  | 'origin_not_allowed'
  /** The token does not permit this surface. Widen at mint, within the session's ceiling. */
  | 'capability_missing'
  /** The organisation is not licensed. Commercial, not technical. */
  | 'entitlement_denied'
  /** A patient token rendered a clinician-only component. */
  | 'role_not_permitted'
  /** Temporarily down. The surface renders its own notice. */
  | 'surface_unavailable'
  /** Transport failure. Surfaces recover their own state on reconnect. */
  | 'network_error'
  /** A browser extension or storage restriction blocked the frame. Not your bug. */
  | 'render_blocked'

export interface PlaySpaceEmbedError {
  code: PlaySpaceErrorCode
  /** Safe to show a user. Never carries clinical content. */
  message: string
  /** Present when the failure reached our servers. Log it. */
  requestId: string | null
  surface: Surface | null
  recoverable: boolean
}

// ─── Low-level escape hatch ──────────────────────────────────────────────────

export interface MountOptions {
  surface: Surface
  fetchToken: () => Promise<string>
  onEvent?: (event: PlaySpaceEvent) => void
  onError?: (error: PlaySpaceEmbedError) => void
  theme?: PlaySpaceTheme
  locale?: string
  props?: Record<string, unknown>
}

/**
 * Mount a surface without React. Re-exported from `@playspace/embed`, which is
 * the transport this package is built on.
 */
export declare function mount(
  element: HTMLElement,
  options: MountOptions
): { unmount: () => void; update: (props: Record<string, unknown>) => void }