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.
template-pages.ts
// Writes a static page for each board template (/templates/<id>) and a templates index (/templates) at
// build time. Search engines and AI assistants read them without running JavaScript: each shows the
// template's real columns and notes (or its diagram, laid out the same way the board draws it).
import fs from "node:fs";
import type { Plugin } from "vite";
import { COLORS, TEMPLATES, type Template } from "./functions/lib/canvas";
import { edgePoint, layoutGraph } from "./src/lib/layout";
const SITE = "https://canvas-free.openapps.xyz";
const UPDATED = "2026-10-19";
interface Copy { title: string; h1: string; description: string; intro: string; uses: string[]; tips: string[] }
const COPY: Record<string, Copy> = {
brainstorm: {
title: "Free online brainstorm board template, no sign-up | Canvas",
h1: "Free brainstorm template",
description: "A free online brainstorming whiteboard: sticky notes for ideas, then themes, top picks and next steps. Share a link and brainstorm together live. No sign-up, no board limit.",
intro: "Everyone adds ideas on sticky notes at the same time, then you drag similar ones together, pick the best and agree who does what. The board starts with a question at the top and four frames: Ideas, Themes, Top picks and Next steps.",
uses: ["Team brainstorms and workshops, in the room or remote", "Planning a product, an event or a campaign", "Students working on a group project"],
tips: ["Give everyone five quiet minutes to add notes before anyone talks.", "Use the laser pointer (K) to point at a note while you talk about it.", "Draw a dot on the notes you like best, then move the winners to Top picks."],
},
retro: {
title: "Free retrospective board template, no sign-up | Canvas",
h1: "Free retrospective template",
description: "A free online retro board: Went well, Could be better, Ideas to try and Action items, with sticky notes everyone adds live. Share a link, no sign-up, no limit on boards.",
intro: "A sprint or project retro in four columns. Everyone adds sticky notes about what went well and what could be better, you talk through ideas to try, and you leave with action items that each have an owner and a date.",
uses: ["Agile teams at the end of each sprint", "Looking back on a project, a launch or an event", "One-on-ones and personal reviews"],
tips: ["Start with Went well, so the meeting does not begin with problems.", "Group notes that say the same thing before you discuss them.", "Keep the board: make a new one from the template next time and compare."],
},
"user-flow": {
title: "Free user flow diagram template, online | Canvas",
h1: "Free user flow template",
description: "A free user flow diagram template: steps, a decision and arrows that stay attached when you move things. Edit it together live in your browser, no sign-up.",
intro: "A sign-up flow drawn as a flowchart: where a person starts, the screens they see, the decision that splits the path, and where they end up. Rename the steps, add your own, and the arrows follow the shapes as you move them.",
uses: ["Product designers mapping a feature before wireframes", "Founders and product managers explaining a flow to the team", "Developers and testers listing every path through a screen"],
tips: ["Use a diamond for every decision and label each arrow out of it.", "Keep one happy path running straight down, and branch the rest to the side.", "Select a step and press Ctrl+D (Cmd+D on a Mac) to make a copy of it."],
},
"weekly-plan": {
title: "Free weekly planner whiteboard template | Canvas",
h1: "Free weekly plan template",
description: "Plan your week on a free online whiteboard: goals plus a column for each day, with sticky notes you drag between days. Share it with your team or family, no sign-up.",
intro: "Write the three things that matter most this week, then put each task on the day you will do it. Drag notes between days as plans change, and share the link so everyone sees the same week.",
uses: ["Planning your own week, at work or at home", "Small teams that plan in weekly cycles", "Families sharing meals, chores and errands"],
tips: ["Add one note per task and keep each one short.", "Color notes by area: yellow for work, green for home, orange for errands.", "At the end of the week, drag what is left to next week's board."],
},
};
const esc = (s: string) => s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]!);
const json = (v: unknown) => JSON.stringify(v).replace(/</g, "\\u003c");
function columnsPreview(t: Template): string {
return `<div class="tpl-preview" aria-label="${esc(t.name)} template preview">${t.columns!.map((c) => `<section class="tpl-col"><h3>${esc(c.name)}</h3>${c.notes.map((n) => `<div class="tpl-note" style="background:${COLORS[c.color]}">${esc(n)}</div>`).join("")}</section>`).join("")}</div>`;
}
/** The diagram as an SVG: shapes where dagre puts them, arrows from edge to edge. */
function flowPreview(t: Template): string {
const graph = t.graph!;
const { nodes, width, height } = layoutGraph(graph);
const at = new Map(nodes.map((n) => [n.id, n]));
const center = (n: (typeof nodes)[number]) => ({ x: n.x + n.width / 2, y: n.y + n.height / 2 });
const edges = graph.edges.map((e) => {
const a = at.get(e.from)!;
const b = at.get(e.to)!;
const p = edgePoint(a, center(b).x, center(b).y, 2);
const q = edgePoint(b, center(a).x, center(a).y, 4);
const label = e.label ? `<text x="${(p.x + q.x) / 2 + 8}" y="${(p.y + q.y) / 2}" class="tpl-flow-label">${esc(e.label)}</text>` : "";
return `<line x1="${p.x.toFixed(1)}" y1="${p.y.toFixed(1)}" x2="${q.x.toFixed(1)}" y2="${q.y.toFixed(1)}" stroke="#1e1e1e" stroke-width="1.6" marker-end="url(#arrow)"/>${label}`;
}).join("");
const shapes = nodes.map((n) => {
const c = center(n);
const fill = COLORS[n.color];
const shape = n.shape === "ellipse"
? `<ellipse cx="${c.x}" cy="${c.y}" rx="${n.width / 2}" ry="${n.height / 2}" fill="${fill}" stroke="#1e1e1e" stroke-width="1.6"/>`
: n.shape === "diamond"
? `<polygon points="${c.x},${n.y} ${n.x + n.width},${c.y} ${c.x},${n.y + n.height} ${n.x},${c.y}" fill="${fill}" stroke="#1e1e1e" stroke-width="1.6"/>`
: `<rect x="${n.x}" y="${n.y}" width="${n.width}" height="${n.height}" rx="14" fill="${fill}" stroke="#1e1e1e" stroke-width="1.6"/>`;
return `${shape}<text x="${c.x}" y="${c.y}" text-anchor="middle" dominant-baseline="central" class="tpl-flow-text">${esc(n.label)}</text>`;
}).join("");
const pad = 12;
return `<div class="tpl-flow"><svg viewBox="${-pad} ${-pad} ${width + pad * 2} ${height + pad * 2}" width="${Math.round(width * 0.8)}" role="img" aria-label="${esc(t.name)} diagram: ${esc(nodes.map((n) => n.label).join(", "))}">
<defs><marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse"><path d="M0 0 10 5 0 10z" fill="#1e1e1e"/></marker></defs>
<style>.tpl-flow-text{font:600 17px "Archivo Variable",system-ui,sans-serif;fill:#1e1e1e}.tpl-flow-label{font:500 15px "Archivo Variable",system-ui,sans-serif;fill:#5d6169}</style>
${edges}${shapes}</svg></div>`;
}
function page(o: { path: string; title: string; description: string; body: string; css: string[]; header: string; footer: string; ld: unknown[] }) {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${esc(o.title)}</title>
<meta name="description" content="${esc(o.description)}">
<link rel="canonical" href="${SITE}${o.path}">
<meta property="og:type" content="website">
<meta property="og:site_name" content="OpenApps">
<meta property="og:title" content="${esc(o.title.replace(/ \| Canvas$/, ""))}">
<meta property="og:description" content="${esc(o.description)}">
<meta property="og:url" content="${SITE}${o.path}">
<meta property="og:image" content="https://openapps.free/og/canvas.png">
<meta name="twitter:card" content="summary_large_image">
<meta name="theme-color" content="#fdfdfc">
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="alternate" type="text/markdown" title="Canvas in Markdown" href="/index.md">
<link rel="alternate" type="text/plain" title="llms.txt" href="/llms.txt">
<link rel="service-desc" type="application/vnd.oai.openapi+json" title="Canvas API" href="/openapi.json">
<link rel="preload" href="/fonts/NewsreaderVariable-var.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="/fonts/ArchivoVariable-var.woff2" as="font" type="font/woff2" crossorigin>
<link rel="stylesheet" href="/kit.css">
${o.css.map((c) => `<link rel="stylesheet" href="/${c}">`).join("\n")}
${o.ld.map((x) => `<script type="application/ld+json">${json(x)}</script>`).join("\n")}
</head>
<body>
${o.header}
<main>
${o.body}
</main>
${o.footer}
</body>
</html>
`;
}
export function templatePages(): Plugin {
return {
name: "canvas-template-pages",
apply: "build",
generateBundle(_, bundle) {
const html = fs.readFileSync("index.html", "utf8");
const header = html.match(/<header class="site-head">[\s\S]*?<\/header>/)![0];
const footer = html.match(/<footer class="site-foot">[\s\S]*?<\/footer>/)![0];
const entry = Object.values(bundle).find((f) => f.type === "chunk" && f.isEntry) as { viteMetadata?: { importedCss: Set<string> } } | undefined;
const css = [...(entry?.viteMetadata?.importedCss ?? [])];
const crumbs = (items: [string, string][]) => ({
"@context": "https://schema.org", "@type": "BreadcrumbList",
itemListElement: items.map(([name, path], i) => ({ "@type": "ListItem", position: i + 1, name, item: `${SITE}${path}` })),
});
const shown = TEMPLATES.filter((t) => COPY[t.id]);
const short = (t: Template) => COPY[t.id].h1.replace(/^Free /, "").replace(/^./, (m) => m.toUpperCase());
for (const t of shown) {
const c = COPY[t.id];
const path = `/templates/${t.id}`;
const what = t.columns
? `${t.heading ? `A heading, “${esc(t.heading)}”, and ` : ""}${t.columns.length} frames of sticky notes: ${t.columns.map((col) => esc(col.name)).join(", ")}.`
: `${t.graph!.nodes.length} shapes joined by ${t.graph!.edges.length} arrows: ${t.graph!.nodes.map((n) => esc(n.label)).join(", ")}.`;
const others = shown.filter((x) => x.id !== t.id).map((x) => `<li><a href="/templates/${x.id}"><strong>${esc(short(x))}</strong><span>${esc(x.summary)}</span></a></li>`).join("");
const body = `
<section class="app-hero">
<div class="wrap tpl-hero">
<nav class="crumbs" aria-label="Breadcrumb"><a href="/">Canvas</a> / <a href="/templates">Templates</a> / <span aria-current="page">${esc(t.name)}</span></nav>
<p class="eyebrow">Free template</p>
<h1 class="display">${esc(c.h1)}</h1>
<p class="lede">${esc(c.intro)}</p>
<p class="tpl-cta"><a class="btn btn-lg" href="/new/${t.id}" rel="nofollow">Use this template, free</a><span class="hint">No sign-up. You get a link to share.</span></p>
</div>
</section>
<section class="section-tight">
<div class="wrap">
<h2 class="h2">What is on the board</h2>
<p class="muted" style="margin-top:8px">${what} Everything can be moved, renamed, recolored or deleted once the board is yours.</p>
${t.columns ? columnsPreview(t) : flowPreview(t)}
</div>
</section>
<section class="section-tight">
<div class="wrap tpl-cols">
<div><h2 class="h2">Good for</h2><ul class="tpl-list">${c.uses.map((u) => `<li>${esc(u)}</li>`).join("")}</ul></div>
<div><h2 class="h2">Tips</h2><ul class="tpl-list">${c.tips.map((u) => `<li>${esc(u)}</li>`).join("")}</ul></div>
</div>
</section>
<section class="section-tight">
<div class="wrap">
<h2 class="h2">Free, like the rest of Canvas</h2>
<p class="lede" style="margin-top:14px">Canvas is a free online whiteboard and a free alternative to Miro: no sign-up, no paid plan and no limit on boards. Share the edit link and everyone draws together live, with their pointers on screen. <a href="/">More about Canvas</a>.</p>
<h2 class="h2" style="margin-top:40px">Other templates</h2>
<ul class="tpl-links">${others}</ul>
</div>
</section>`;
const ld = [
{ "@context": "https://schema.org", "@type": "WebPage", name: c.h1, description: c.description, url: `${SITE}${path}`, dateModified: UPDATED, isPartOf: { "@type": "WebSite", name: "Canvas by OpenApps", url: `${SITE}/` } },
crumbs([["Canvas", "/"], ["Templates", "/templates"], [t.name, path]]),
];
this.emitFile({ type: "asset", fileName: `templates/${t.id}.html`, source: page({ path, title: c.title, description: c.description, body, css, header, footer, ld }) });
}
const indexDesc = "Free whiteboard templates: Brainstorm, Retro, User flow and Weekly plan. Start a shared board in one click, no sign-up.";
const indexBody = `
<section class="app-hero">
<div class="wrap tpl-hero">
<nav class="crumbs" aria-label="Breadcrumb"><a href="/">Canvas</a> / <span aria-current="page">Templates</span></nav>
<p class="eyebrow">Free templates</p>
<h1 class="display">Free whiteboard templates</h1>
<p class="lede">Start a shared board in one click from a ready-made layout. Every template is free, needs no sign-up, and works with your whole team through one link.</p>
</div>
</section>
<section class="section-tight">
<div class="wrap">
<ul class="tpl-links">${shown.map((t) => `<li><a href="/templates/${t.id}"><strong>${esc(short(t))}</strong><span>${esc(COPY[t.id].description)}</span></a></li>`).join("")}</ul>
<p style="margin-top:24px">Or <a href="/new" rel="nofollow">start a blank whiteboard</a>.</p>
</div>
</section>`;
this.emitFile({
type: "asset", fileName: "templates.html",
source: page({
path: "/templates", title: "Free whiteboard templates: brainstorm, retro, user flow | Canvas", description: indexDesc, body: indexBody, css, header, footer,
ld: [
{ "@context": "https://schema.org", "@type": "CollectionPage", name: "Free whiteboard templates", description: indexDesc, url: `${SITE}/templates`, dateModified: UPDATED, hasPart: shown.map((t) => ({ "@type": "WebPage", name: COPY[t.id].h1, url: `${SITE}/templates/${t.id}` })) },
crumbs([["Canvas", "/"], ["Templates", "/templates"]]),
],
}),
});
},
};
}
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