import type { BillingInterval, WorkspaceStatus } from './billing.ts' import type { CanvasRole, LinkAccess } from './canvasRole.ts' import type { DesignToken } from './designTokens.ts' import type { RunnerPick } from './agentRunner.ts' export interface Frame { id: string canvasId: string /** Unset on legacy frames: belongs to the first page. */ pageId?: string name: string x: number y: number width: number height: number html: string createdAt: number updatedAt: number updatedBy: string /** product-made onboarding/example content (welcome demo, seeded frames) — * not the user's work; agents must never read it as the canvas's style */ demo?: boolean /** hidden from the stage (the eye in the Layers panel); the frame keeps * its row, its HTML and its exports — only the canvas stops drawing it */ hidden?: boolean /** Figma-style lock: the frame cannot be selected, moved, resized or * deleted from the stage until it is unlocked (Layers panel, Inspector, * ⌘⇧L, or an agent via update_frame) */ locked?: boolean /** Stacking order on its page (back to front; see shared/frameStack.ts). * Unset on frames made before stacking existed: they stack by createdAt. */ z?: number /** Human sign-off for implementation. Missing/null means draft. Design * edits retain the attribution but change the status to needs_review. */ approval?: FrameApproval | null } export interface FrameApproval { status: 'ready_for_dev' | 'needs_review' approvedBy: string approvedByUserId: string approvedAt: number /** The frame revision the person reviewed before approving. */ approvedFrameUpdatedAt: number } /** One entry of a frame's design history: the HTML (and size) the frame had * before a later burst of edits replaced it. The listing omits the HTML. */ export interface FrameVersionInfo { id: string frameId: string canvasId: string width: number height: number /** who authored that state, and when it was last written */ savedBy: string savedByKind?: ActorKind savedAt: number htmlBytes: number } export interface FrameVersion extends FrameVersionInfo { html: string } export interface CanvasMeta { id: string name: string /** user id of the creator; unset for legacy/seeded canvases (visible to everyone) */ ownerId?: string /** true when this canvas is on the list because the user was invited */ shared?: boolean /** the shared workspace it lives in; unset = a personal canvas */ workspaceId?: string createdAt: number updatedAt: number frameCount: number /** most recently updated frame — render /i/.jpg for a canvas preview */ previewFrameId?: string /** agents that have worked on this canvas (most recent first) */ agents?: { name: string; owner?: string; lastAt?: number }[] /** the owner first, then everyone invited — the home tile's avatar stack; * `image` is a profile photo's URL when the person uploaded one */ people?: { id: string; name: string; image?: string }[] } /** The dashboard/gallery preview render (`/i/.jpg?preview`) clips a * frame at this many frame pixels of height — a tall page's thumbnail is * its top section, not the whole page. Anything sizing a tile around that * image must assume this cap, not the frame's real height. */ export const PREVIEW_MAX_HEIGHT = 1200 /* ---- community gallery ---- */ export const COMMUNITY_CATEGORIES = ['website', 'app', 'dashboard', 'mobile', 'marketing', 'other'] as const export type CommunityCategory = (typeof COMMUNITY_CATEGORIES)[number] export function isCommunityCategory(value: unknown): value is CommunityCategory { return typeof value === 'string' && (COMMUNITY_CATEGORIES as readonly string[]).includes(value) } export const COMMUNITY_CATEGORY_LABELS: Record = { website: 'Websites', app: 'Web apps', dashboard: 'Dashboards', mobile: 'Mobile', marketing: 'Marketing', other: 'Other', } /** One gallery card: what the community sees of a published canvas. * Frames are listed by id and size only — previews render through the * public /i/ image pipeline, the HTML never leaves the owner's canvas. */ export interface CommunityItem { id: string name: string description?: string category: CommunityCategory authorName: string publishedAt: number updatedAt: number copyCount: number frames: { id: string; name: string; width: number; height: number }[] } export interface CanvasPageInfo { id: string name: string /** the frame this page's prototype flow starts on (shared/prototype.ts). * Set only through the flow-start route or MCP tool; page-list saves keep * whatever the server already has. */ startFrameId?: string /** the page's own canvas colour (`#rrggbb`, shared/pageBackground.ts), * shown in place of the viewer's theme paper. Set only through the * background route or MCP tool; page-list saves keep it, as they keep * the flow start. */ background?: string } /** A ruler guide: an infinite line on one page of a canvas, in world px. * 'v' is a vertical line at x = `at`, 'h' a horizontal one at y = `at` * (the same convention as the alignment snap guides). */ export interface CanvasGuide { id: string pageId: string axis: 'v' | 'h' at: number } export interface Canvas { id: string name: string ownerId?: string /** what the share link grants people who are not the owner or invited: * 'view', 'comment' or 'edit', or 'none' (private — the default; unset * means 'none'). See shared/canvasRole.ts. */ linkAccess?: LinkAccess /** the caller's own role, set on the copy a canvas GET or a room `init` * hands one person; never stored */ role?: CanvasRole /** user ids invited to collaborate (the owner is not listed) */ memberIds?: string[] /** the shared workspace this canvas belongs to — every workspace member * can open it, on top of the owner and invited members. Unset = the * owner's personal space. */ workspaceId?: string /** set while the owner lists this canvas in the community gallery. The * gallery shows previews and hands out copies — it never opens the * canvas itself, so publishing does not change who can edit it. */ publishedAt?: number /** gallery blurb; meaningful only while published */ description?: string /** gallery shelf; meaningful only while published */ category?: CommunityCategory /** copies handed out by the gallery — its "trending" signal */ copyCount?: number createdAt: number updatedAt: number frames: Frame[] pages?: CanvasPageInfo[] /** ruler guides of every page, each tagged with its page */ guides?: CanvasGuide[] /** named design docs (brand rules, style recipes) every actor on the canvas follows */ guidelines?: GuidelineDoc[] /** design tokens (shared/designTokens.ts): the CSS custom properties frames use as var(--name) */ tokens?: DesignToken[] /** the token themes (light, dark, …) in display order; a token may carry a value per theme */ tokenThemes?: string[] /** frames pinned to Memory as style exemplars — HTML snapshotted at pin time */ references?: MemoryReference[] } /* ---- workspaces ---- */ export const WORKSPACE_ROLES = ['owner', 'admin', 'member'] as const export type WorkspaceRole = (typeof WORKSPACE_ROLES)[number] export function isWorkspaceRole(value: unknown): value is WorkspaceRole { return typeof value === 'string' && (WORKSPACE_ROLES as readonly string[]).includes(value) } /** A workspace as the dashboard lists it — for one viewer, hence `role`. */ export interface WorkspaceSummary { id: string name: string ownerId: string /** the viewer's role in it */ role: WorkspaceRole status: WorkspaceStatus /** true when members may grow it: no billing on this server, or a live * subscription. Canvases inside stay reachable either way. */ active: boolean plan: 'team' | null interval: BillingInterval | null /** seats Stripe is billing for right now */ seats: number memberCount: number canvasCount: number /** next renewal (or the end, when cancelAtPeriodEnd) — epoch ms */ currentPeriodEnd?: number cancelAtPeriodEnd: boolean createdAt: number updatedAt: number } export interface WorkspaceMember { userId: string name: string email: string /** the profile photo's URL when the person uploaded one */ image?: string role: WorkspaceRole addedAt: number } /** An outstanding invite to an email with no doop account yet. */ export interface WorkspaceInvite { id: string email: string role: WorkspaceRole invitedByName: string createdAt: number } export interface WorkspaceDetail extends WorkspaceSummary { members: WorkspaceMember[] /** only admins and the owner see these */ invites: WorkspaceInvite[] billing: { /** Stripe is configured on this server — plans can be bought */ enabled: boolean /** a Stripe customer exists, so the billing portal can open */ portal: boolean } } /* ---- design memory ---- */ /** A frame pinned to Memory as a style reference: "more like this one". * The HTML is snapshotted at pin time, so later edits to (or deletion of) * the frame never change what the exemplar shows. */ export interface MemoryReference { id: string /** the frame it was pinned from; the frame may no longer exist */ frameId: string title: string html: string width: number height: number pinnedBy: string pinnedAt: number } /** A resolved design decision, captured automatically when an agent addresses * human feedback or an @agent element comment gets resolved. Raw material * the distiller condenses into rule proposals. */ export interface DesignDecision { id: string /** the human's words — what they asked to change */ text: string /** the generalized preference distilled from the raw words shortly after * capture (e.g. "Prefer white and blue; no italic serif") — what the UI * leads with; absent until the summarizer has run (or without an API key) */ summary?: string /** where the decision came from: task feedback, an @agent element comment, * or the human's own conversation with a connected agent (save_decision) */ source: 'feedback' | 'comment' | 'chat' frameId?: string /** the human who gave the feedback */ from: string /** the agent that addressed it */ agentName?: string at: number /** consumed by a distiller run (whether or not it yielded a proposal) */ distilledAt?: number } /** A rule edit the distiller proposes from accumulated decisions. Nothing is * written to a guide until a human accepts — memory is curated, not scraped. */ export interface MemoryProposal { id: string /** slug of the guide the rule should land in (existing or new) */ guideName: string /** pretty display name, used when the guide doesn't exist yet */ guideTitle?: string /** markdown to append to the guide (usually one bullet) */ rule: string /** why the distiller thinks this is a rule, in one sentence */ rationale: string /** decision ids this was distilled from */ basedOn: string[] at: number status: 'pending' | 'accepted' | 'dismissed' resolvedBy?: string resolvedAt?: number } /** A named design markdown attached to a canvas — palettes, fonts, layout * recipes, asset URLs. Written for agents, readable by humans. Rendered as * a pinned "style guide" card on the canvas next to the frames. */ export interface GuidelineDoc { /** slug, unique per canvas (e.g. "feature-image") */ name: string /** pretty display name (e.g. "Featured Images"); fall back to the slug */ title?: string markdown: string updatedAt: number updatedBy: string /** world position of the card on the canvas; unset = auto-placed */ x?: number y?: number } export type ActorKind = 'user' | 'agent' export interface Actor { name: string kind: ActorKind color: string clientId?: string /** for agents: display name of the user whose OAuth token authorized it */ owner?: string } export interface Presence { clientId: string /** Authenticated identity for people, shared across their connections. */ userId?: string name: string /** a person's profile photo (shared/avatar.ts), else initials */ image?: string color: string kind: ActorKind cursor?: { x: number; y: number } /** the region of the canvas this client is looking at — pan/zoom plus the * stage size, so a follower can fit the same world rect on its own screen */ viewport?: PeerViewport activeFrameId?: string | null /** one-line "what I'm working on right now" (agents set this via set_status) */ status?: string /** for agents: whose token they connected with */ owner?: string } /** A unit of work an agent announced via set_status. A new status completes the previous task. * Board cards are the same object: a human queues one (queuedBy set, agentName empty) * and an agent claims it — cards stay open until explicitly completed. */ export interface AgentTask { id: string /** empty string while a queued card waits for an agent */ agentName: string /** whose token the agent connected with */ owner?: string color: string status: string startedAt: number endedAt?: number /** inferred by the server from frame edits (agent never called set_status) */ auto?: boolean /** when the run this status belongs to began: carried from each status to * the next, so the Live Activity's start survives the task log's trim. * Live only — after a restart the start is rebuilt from the retained log */ runStartedAt?: number /** frames the agent edited while this task was open, most recent last — * lets the Agents panel jump the camera to where the work happened */ frameIds?: string[] /** human who queued this as a board card */ queuedBy?: string /** account id of that human — decides which model credential runs the card */ queuedByUserId?: string claimedAt?: number /** unsuccessful agent attempt; failed work waits for an explicit human retry */ failedAt?: number failureReason?: string /** board cards: ordered agent-role ids the card walks through, one at a time. * Absent on status tasks and on cards queued before pipelines existed. */ pipeline?: string[] /** board cards: ids of reference image frames uploaded with the prompt */ attachments?: string[] /** index into pipeline of the stage that is queued or running right now */ stage?: number /** board cards: the frame (and optionally the element inside it) the human * had selected when they asked — the agent edits that in place */ scope?: CardScope /** structured board cards the resident runner dispatches on, instead of * handing the title to the chat agent. Absent on prompt cards. */ kind?: RepoCardKind payload?: RepoCardPayload /** board cards: the connection and model the composer picked for this * card; absent runs on the requester's Settings default */ runner?: RunnerPick } export interface CardScope { frameId: string /** element selector inside the frame (frameRuntime cssPath); absent = whole frame */ selector?: string } export type RepoCardKind = 'sketch' | 'design-system' /** A screen of a connected GitHub repository, as the import manifest lists it. */ export interface RepoScreenRef { kind: 'page' | 'story' | 'component' | 'static' route: string sourcePath: string title: string /** where the pixels come from: repo HTML, or the agent's sketch of the code */ source: 'static' | 'placeholder' } /** What a repo card carries: enough to run it from any process, later. The * connection is looked up by id at run time, so no credential is ever here. */ export interface RepoCardPayload { connectionId: string repo: string /** one import = one click; groups the cards it queued on the board */ importId: string /** the screen to sketch — absent on a design-system card */ screen?: RepoScreenRef } /** Human feedback left on an agent task: an open request on the canvas that ANY * agent can pick up — delivered inside the next identified agent tool result. */ export interface TaskFeedback { id: string taskId: string canvasId: string /** whose work the feedback is about (the task's agent), not who must handle it */ agentName: string /** the resident agent this is routed to; unset = open to any agent */ targetAgent?: string from: string /** account id of the human who left it — decides which model credential runs it */ fromUserId?: string /** the connection the task it answers ran on, kept from when it was left, * so the reply runs there even after the task leaves the history */ runner?: RunnerPick text: string at: number /** set once the feedback has been included in some agent's tool result */ deliveredAt?: number /** the agent that picked it up */ claimedBy?: string /** resident Doop finished handling this feedback */ completedAt?: number /** unsuccessful resident-agent attempt; never retried automatically */ failedAt?: number failureReason?: string } /** A comment pinned to a specific element inside a frame, to a frame as a * whole, or to a point on the canvas itself. Comments that mention @Doop are * routed to the resident agent; others are notes for the humans in the room. * * Three anchors (`shared/comments.ts` tells them apart): an element comment * carries `frameId` + `elementId` (the element's `data-doop-id` in the frame * HTML) + `snippet`; a frame comment carries `frameId` + `x` + `y` (design * px) and no `elementId`; a canvas comment carries `pageId` + `x` + `y` * (world coordinates) and no `frameId`. An element comment whose element * could not be found again has `frameId` and `snippet` only. */ export interface ElementComment { id: string canvasId: string /** the frame the element lives in; absent on a canvas comment */ frameId?: string /** the anchored element's `data-doop-id`; absent on frame and canvas comments */ elementId?: string /** outerHTML excerpt of the element, for agent context and dead-anchor display ('' off an element) */ snippet: string /** canvas comment: the page it sits on … */ pageId?: string /** Clicked point: frame design coordinates, or world coordinates on a canvas page. */ x?: number y?: number /** Element top-left at placement time; a moved element uses its current top-left. */ elementOrigin?: { x: number; y: number } from: string /** account id of the human who left it — decides which model credential runs it */ fromUserId?: string text: string at: number /** true when the text @mentions a resident agent — that agent picks it up */ forAgent?: boolean /** which resident agent was mentioned; defaults to Doop */ targetAgent?: string claimedBy?: string claimedAt?: number /** unsuccessful agent attempt; the comment remains paused until retried */ failedAt?: number failureReason?: string resolvedBy?: string resolvedAt?: number /** set on a reply: the root comment of its thread. Replies inherit the * root's anchor and are listed under its pin instead of getting their own */ parentId?: string } export interface ActivityItem { id: string actorName: string actorKind: ActorKind actorColor: string message: string frameId?: string at: number } /** A client's camera: world→screen transform plus the stage size it fills. */ export interface PeerViewport { pageId?: string x: number y: number zoom: number width: number height: number } /* ---- websocket protocol ---- */ export type ClientMessage = | { type: 'join'; canvasId: string; clientId: string; name: string; kind: ActorKind } | { type: 'cursor'; x: number; y: number } | { type: 'viewport'; viewport: PeerViewport } | { type: 'editing'; frameId: string | null } | { type: 'frame:drag'; frameId: string; x: number; y: number; width: number; height: number } export type ServerMessage = | { type: 'init' canvas: Canvas presences: Presence[] activity: ActivityItem[] tasks: AgentTask[] feedback: TaskFeedback[] comments: ElementComment[] decisions: DesignDecision[] proposals: MemoryProposal[] selfColor: string /** id of the client bundle the server is serving; 'dev' outside production */ serverBuild: string } /** the share link changed: the receiver's role on this canvas now */ | { type: 'role'; role: CanvasRole } | { type: 'presence:join'; presence: Presence } | { type: 'presence:leave'; clientId: string } | { type: 'cursor'; clientId: string; x: number; y: number } | { type: 'viewport'; clientId: string; viewport: PeerViewport } | { type: 'editing'; clientId: string; frameId: string | null } | { type: 'status'; clientId: string; status: string | null } | { type: 'task'; task: AgentTask } | { type: 'feedback'; feedback: TaskFeedback } | { type: 'comment'; comment: ElementComment } | { type: 'frame:drag'; clientId: string; frameId: string; x: number; y: number; width: number; height: number } | { type: 'frame:created'; frame: Frame; actor: Actor } | { type: 'frame:updated'; frame: Frame; actor: Actor } | { type: 'frame:deleted'; frameId: string; actor: Actor } | { type: 'frame:streaming'; frameId: string; active: boolean; actor: Actor } | { type: 'canvas:renamed'; name: string; actor: Actor } | { type: 'canvas:pages'; pages: CanvasPageInfo[]; actor: Actor } /** a ruler guide was added or moved (guide set) or removed (null) */ | { type: 'guide'; id: string; guide: CanvasGuide | null; actor: Actor } /** a style-guide doc was written, moved (doc set) or deleted (doc null) */ | { type: 'guidelines'; name: string; doc: GuidelineDoc | null; actor: Actor } /** the canvas's design tokens changed: the whole list after the change */ | { type: 'tokens'; tokens: DesignToken[]; themes: string[]; actor: Actor } /** a frame was pinned to (reference set) or unpinned from (null) Memory */ | { type: 'reference'; id: string; reference: MemoryReference | null; actor: Actor } /** a design decision was captured into Memory */ | { type: 'decision'; decision: DesignDecision } /** the distiller proposed a rule, or a proposal was accepted/dismissed */ | { type: 'proposal'; proposal: MemoryProposal } | { type: 'canvas:deleted' } | { type: 'activity'; item: ActivityItem } export const CURSOR_PALETTE = [ '#2743EE', // cursor blue — the brand accent leads '#0E9F6E', // green '#8B5CF6', // violet '#D0341F', // vermillion '#C77800', // amber '#D62A7E', // magenta '#0E8A8A', // teal '#8E2E5C', // plum ] export function colorFor(key: string): string { let h = 0 for (let i = 0; i < key.length; i++) h = (h * 31 + key.charCodeAt(i)) >>> 0 /* the modulo keeps the index in range */ return CURSOR_PALETTE[h % CURSOR_PALETTE.length]! }