Source
This app’s code and revision history are public. Download a copy or explore the files below.
Public
The app’s code and revision history are public.
Run it yourself
Take the whole tree and run it on your own machine. The download needs no account. Cloning needs a sign-in.
Download .zip
bounded clone 788ba0fa01083b300ad50913
A copy you download is not bounded. The guarantees this app runs under - declared egress, proven invariants, writes that fail closed - live in the runtime serving this page, not in the files. What you get is auditable source you can read and run, with no runtime guarantees attached.
functions/lib/canvas.ts
// Canvas's shared logic: what a new board starts with (a template, a graph or columns of sticky notes),
// the checks on that input, and the plain summary of a board the API hands to assistants.
// Used by the web app, the public API and the build (template pages). No imports: functions can only
// import inside functions/.
export const LIMITS = {
title: 120,
nodes: 150,
edges: 300,
label: 200,
columns: 12,
notes: 60,
note: 300,
/** One element's JSON as stored. A long freehand stroke is the largest common element. */
element: 100_000,
/** Image files, after Canvas shrinks them. */
imageBytes: 2 * 1024 * 1024,
/** The API reads at most this many elements of a board, and this much of each text, so a huge board cannot sink it. */
readElements: 5_000,
readText: 1_000,
};
/** UTF-8 bytes: what the policy's length checks count. */
export const byteLength = (s: string) => new TextEncoder().encode(s).length;
export const IMAGE_TYPES = ["image/png", "image/jpeg", "image/webp"];
/** Fill colors for notes and shapes, from Excalidraw's palette. */
export const COLORS = {
yellow: "#ffec99",
green: "#b2f2bb",
blue: "#a5d8ff",
red: "#ffc9c9",
violet: "#d0bfff",
orange: "#ffd8a8",
gray: "#e9ecef",
white: "#ffffff",
} as const;
export type Color = keyof typeof COLORS;
export const COLOR_NAMES = Object.keys(COLORS) as Color[];
export type Shape = "rectangle" | "ellipse" | "diamond";
export const SHAPES: Shape[] = ["rectangle", "ellipse", "diamond"];
export interface GraphNode { id: string; label: string; shape?: Shape; color?: Color }
export interface GraphEdge { from: string; to: string; label?: string }
export interface Graph { nodes: GraphNode[]; edges: GraphEdge[]; direction: "down" | "right" }
export interface Column { name: string; color: Color; notes: string[] }
/** What a new board starts with. Stored on the board as JSON and drawn by the first editor who opens it. */
export type Seed =
| { template: TemplateId }
| { graph: Graph; heading?: string }
| { columns: Column[]; heading?: string };
export type TemplateId = "brainstorm" | "retro" | "user-flow" | "weekly-plan";
export interface Template {
id: TemplateId;
name: string;
summary: string;
title: string;
heading?: string;
columns?: Column[];
graph?: Graph;
}
export const TEMPLATES: Template[] = [
{
id: "brainstorm",
name: "Brainstorm",
summary: "Ideas on sticky notes, grouped into themes, then next steps.",
title: "Brainstorm",
heading: "What should we try next?",
columns: [
{ name: "Ideas", color: "yellow", notes: ["One idea per note", "Go for quantity first, judge later", "Build on someone else's idea", "", "", ""] },
{ name: "Themes", color: "blue", notes: ["Drag similar ideas next to each other and name the group"] },
{ name: "Top picks", color: "violet", notes: ["Draw a dot on the ideas you like best"] },
{ name: "Next steps", color: "green", notes: ["Who does what, by when"] },
],
},
{
id: "retro",
name: "Retro",
summary: "What went well, what could be better, and action items.",
title: "Team retro",
heading: "Retro: looking back on the last two weeks",
columns: [
{ name: "Went well", color: "green", notes: ["Shipped the new sign-up flow", ""] },
{ name: "Could be better", color: "red", notes: ["Too many meetings on Tuesdays", ""] },
{ name: "Ideas to try", color: "yellow", notes: ["Pair on the hardest bug each week", ""] },
{ name: "Action items", color: "blue", notes: ["Each with an owner and a date", ""] },
],
},
{
id: "user-flow",
name: "User flow",
summary: "A sign-up flow with steps, a decision and arrows that stay attached.",
title: "User flow",
graph: {
direction: "down",
nodes: [
{ id: "start", label: "Opens the app", shape: "ellipse", color: "green" },
{ id: "landing", label: "Landing page", color: "white" },
{ id: "account", label: "Has an account?", shape: "diamond", color: "yellow" },
{ id: "signin", label: "Sign in", color: "white" },
{ id: "signup", label: "Sign up", color: "white" },
{ id: "confirm", label: "Confirm email", color: "white" },
{ id: "home", label: "Home screen", color: "blue" },
{ id: "done", label: "Starts first task", shape: "ellipse", color: "green" },
],
edges: [
{ from: "start", to: "landing" },
{ from: "landing", to: "account" },
{ from: "account", to: "signin", label: "Yes" },
{ from: "account", to: "signup", label: "No" },
{ from: "signup", to: "confirm" },
{ from: "signin", to: "home" },
{ from: "confirm", to: "home" },
{ from: "home", to: "done" },
],
},
},
{
id: "weekly-plan",
name: "Weekly plan",
summary: "Goals for the week and a column for each day.",
title: "Weekly plan",
heading: "This week",
columns: [
{ name: "Goals", color: "green", notes: ["The three things that matter most this week", "", ""] },
{ name: "Monday", color: "yellow", notes: [""] },
{ name: "Tuesday", color: "yellow", notes: [""] },
{ name: "Wednesday", color: "yellow", notes: [""] },
{ name: "Thursday", color: "yellow", notes: [""] },
{ name: "Friday", color: "yellow", notes: [""] },
{ name: "Weekend", color: "orange", notes: [""] },
],
},
];
export const templateById = (id: string) => TEMPLATES.find((t) => t.id === id);
/** A template, graph or columns seed, with the template expanded. */
export function expandSeed(seed: Seed): { graph?: Graph; columns?: Column[]; heading?: string } {
if ("template" in seed) {
const t = templateById(seed.template);
return t ? { graph: t.graph, columns: t.columns, heading: t.heading } : {};
}
return seed;
}
/** A stored seed, checked again with the API's limits: a board made straight through the database can hold any seed. */
export function parseSeed(raw: string | null | undefined): Seed | null {
if (!raw) return null;
try {
const s = JSON.parse(raw);
if (!s || typeof s !== "object" || Array.isArray(s)) return null;
const made = seedFromInput("graph" in s ? { ...s.graph, heading: s.heading } : s);
return "seed" in made ? made.seed : null;
} catch {
return null;
}
}
const text = (v: unknown, max: number) => String(v ?? "").replace(/\s+/g, " ").trim().slice(0, max);
/**
* Checks what an assistant sent to start a board and returns the seed to store, or an error sentence.
* Accepts {template}, {nodes, edges, direction?} or {columns: [{name, color?, notes}]}.
*/
export function seedFromInput(input: Record<string, any>): { seed: Seed | null } | { error: string } {
const kinds = ["template", "nodes", "columns"].filter((k) => input[k] != null);
if (kinds.length > 1) return { error: "Send one of template, nodes (with edges) or columns, not several." };
const heading = input.heading == null ? undefined : text(input.heading, LIMITS.label) || undefined;
if (input.template != null) {
if (!templateById(String(input.template))) return { error: `template must be one of ${TEMPLATES.map((t) => t.id).join(", ")}.` };
return { seed: { template: String(input.template) as TemplateId } };
}
if (input.nodes != null) {
if (!Array.isArray(input.nodes) || !input.nodes.length) return { error: "nodes must be a non-empty list of {id, label}." };
if (input.nodes.length > LIMITS.nodes) return { error: `A board can start with up to ${LIMITS.nodes} nodes.` };
const nodes: GraphNode[] = [];
const ids = new Set<string>();
for (const [i, n] of input.nodes.entries()) {
if (!n || typeof n !== "object") return { error: `nodes[${i}] must be an object like {"id": "a", "label": "Start"}.` };
const id = text(n.id ?? n.label, 80);
const label = text(n.label ?? n.id, LIMITS.label);
if (!id) return { error: `nodes[${i}] needs an id or a label.` };
if (ids.has(id)) return { error: `Two nodes have the id "${id}". Give each node its own id.` };
if (n.shape != null && !SHAPES.includes(n.shape)) return { error: `nodes[${i}].shape must be one of ${SHAPES.join(", ")}.` };
if (n.color != null && !COLOR_NAMES.includes(n.color)) return { error: `nodes[${i}].color must be one of ${COLOR_NAMES.join(", ")}.` };
ids.add(id);
nodes.push({ id, label, ...(n.shape ? { shape: n.shape } : {}), ...(n.color ? { color: n.color } : {}) });
}
const rawEdges = input.edges ?? [];
if (!Array.isArray(rawEdges)) return { error: "edges must be a list of {from, to}." };
if (rawEdges.length > LIMITS.edges) return { error: `A board can start with up to ${LIMITS.edges} edges.` };
const edges: GraphEdge[] = [];
for (const [i, e] of rawEdges.entries()) {
const from = text(e?.from, 80);
const to = text(e?.to, 80);
if (!ids.has(from) || !ids.has(to)) return { error: `edges[${i}] must connect two node ids from nodes (got "${from}" to "${to}").` };
const label = e?.label == null ? "" : text(e.label, LIMITS.label);
edges.push({ from, to, ...(label ? { label } : {}) });
}
const direction = input.direction == null ? "down" : String(input.direction);
if (direction !== "down" && direction !== "right") return { error: 'direction must be "down" or "right".' };
return { seed: { graph: { nodes, edges, direction }, ...(heading ? { heading } : {}) } };
}
if (input.columns != null) {
if (!Array.isArray(input.columns) || !input.columns.length) return { error: "columns must be a non-empty list of {name, notes}." };
if (input.columns.length > LIMITS.columns) return { error: `A board can start with up to ${LIMITS.columns} columns.` };
const columns: Column[] = [];
for (const [i, c] of input.columns.entries()) {
const name = text(c?.name, LIMITS.label);
const color = c?.color == null ? "yellow" : c.color;
if (!COLOR_NAMES.includes(color)) return { error: `columns[${i}].color must be one of ${COLOR_NAMES.join(", ")}.` };
const notes = c?.notes == null ? [] : c.notes;
if (!Array.isArray(notes)) return { error: `columns[${i}].notes must be a list of strings.` };
if (notes.length > LIMITS.notes) return { error: `A column can start with up to ${LIMITS.notes} notes.` };
columns.push({ name, color, notes: notes.map((n: unknown) => String(n ?? "").trim().slice(0, LIMITS.note)) });
}
return { seed: { columns, ...(heading ? { heading } : {}) } };
}
return { seed: null };
}
/** Excalidraw element fields the summary reads. */
interface El {
id: string; type: string; x: number; y: number; width: number; height: number; isDeleted?: boolean;
text?: string; containerId?: string | null; name?: string | null; frameId?: string | null; backgroundColor?: string;
startBinding?: { elementId: string } | null; endBinding?: { elementId: string } | null; boundElements?: { id: string; type: string }[] | null;
}
// Anyone with the edit link can store any JSON as an element, and Excalidraw trusts what it draws: a
// field of the wrong type stops the board from opening for everyone. Only well-formed elements are drawn.
// Excalidraw's "iframe" elements (pages its AI features make) run their HTML in a frame on every screen,
// so they are never drawn.
type Check = (v: unknown) => boolean;
const str: Check = (v) => typeof v === "string";
const num: Check = (v) => typeof v === "number" && Number.isFinite(v);
const bool: Check = (v) => typeof v === "boolean";
const obj: Check = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
const list = (item: Check): Check => (v) => Array.isArray(v) && v.every(item);
const point: Check = (v) => Array.isArray(v) && v.length === 2 && v.every(num);
const orNull = (check: Check): Check => (v) => v === null || check(v);
const ELEMENT_TYPES = ["rectangle", "diamond", "ellipse", "arrow", "line", "freedraw", "text", "image", "frame", "magicframe", "embeddable"];
const ELEMENT_FIELDS: Record<string, Check> = {
id: str, type: str, version: num, versionNonce: num, seed: num, updated: num, index: orNull(str),
x: num, y: num, width: num, height: num, angle: num, strokeWidth: num, roughness: num, opacity: num,
strokeColor: str, backgroundColor: str, fillStyle: str, strokeStyle: str, isDeleted: bool, locked: bool,
groupIds: list(str), frameId: orNull(str), link: orNull(str), roundness: orNull(obj),
boundElements: orNull(list((b) => obj(b) && str((b as El).id) && str((b as El).type))),
text: str, originalText: str, fontSize: num, fontFamily: num, lineHeight: num, textAlign: str, verticalAlign: str,
containerId: orNull(str), autoResize: bool,
points: list(point), pressures: list(num), simulatePressure: bool, lastCommittedPoint: orNull(point),
startBinding: orNull(obj), endBinding: orNull(obj), startArrowhead: orNull(str), endArrowhead: orNull(str),
elbowed: bool, fixedSegments: orNull(list(obj)),
fileId: orNull(str), status: str, scale: point, crop: orNull(obj), name: orNull(str),
};
/** True for an element Excalidraw can draw safely: a known type, and every known field of the right type. */
export function isElement(v: unknown): boolean {
if (!obj(v)) return false;
const e = v as Record<string, unknown>;
if (!str(e.id) || !num(e.version) || !ELEMENT_TYPES.includes(e.type as string)) return false;
return Object.entries(ELEMENT_FIELDS).every(([field, check]) => e[field] === undefined || check(e[field]));
}
const colorName = (hex?: string) => (COLOR_NAMES.find((c) => COLORS[c] === hex?.toLowerCase()) ?? (hex && hex !== "transparent" ? hex : undefined));
/** A plain description of a board's contents: shapes with their text, arrows between them, frames, loose text. */
export function summarize(elements: El[]) {
const live = elements.filter((e) => e && !e.isDeleted);
const byId = new Map(live.map((e) => [e.id, e]));
const labelOf = (e: El) => {
const t = (e.boundElements || []).map((b) => byId.get(b.id)).find((b) => b?.type === "text");
return t?.text?.trim() || "";
};
const round = (n: number) => Math.round(n);
const frames = live.filter((e) => e.type === "frame" || e.type === "magicframe");
const shapes = live.filter((e) => ["rectangle", "ellipse", "diamond"].includes(e.type));
const arrows = live.filter((e) => e.type === "arrow" || e.type === "line");
const texts = live.filter((e) => e.type === "text" && !e.containerId);
const nameOf = (id?: string | null) => {
const e = id ? byId.get(id) : undefined;
return e ? labelOf(e) || e.text || e.name || e.type : undefined;
};
return {
frames: frames.map((f) => ({ id: f.id, name: f.name || "Frame", items: live.filter((e) => e.frameId === f.id && !e.containerId).length })),
shapes: shapes.map((s) => ({
id: s.id, shape: s.type, text: labelOf(s), color: colorName(s.backgroundColor),
frame: s.frameId ? (byId.get(s.frameId)?.name || "Frame") : undefined, x: round(s.x), y: round(s.y), width: round(s.width), height: round(s.height),
})),
arrows: arrows.map((a) => ({
id: a.id, from: nameOf(a.startBinding?.elementId), to: nameOf(a.endBinding?.elementId), text: labelOf(a) || undefined,
})),
texts: texts.map((t) => ({ id: t.id, text: t.text || "", x: round(t.x), y: round(t.y) })),
drawings: live.filter((e) => e.type === "freedraw").length,
images: live.filter((e) => e.type === "image").length,
elements: live.length,
};
}
const ALPHABET = "abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789";
/** A random id from an unambiguous alphabet; 20 characters is about 116 bits. */
export function randomId(length = 20): string {
const bytes = crypto.getRandomValues(new Uint8Array(length));
let out = "";
for (const b of bytes) out += ALPHABET[b % ALPHABET.length];
return out;
}
Change history
Point OpenApps links to openapps.free
Bind AGENT_ADDRESS in policy constants
Use functions.openapps.xyz alias, add OpenApp contribution strip
Bounded source sync
Bounded source sync
Bounded source sync
Bounded source sync