Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions canvas/server/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,14 @@ export function createAgentServer(options: {
"which someone shared and is not on this machine. `sp fetch " + community + "` prints " +
"a folder holding a copy of it to read, and `sp duplicate " + community + "` makes it a " +
"new project of theirs, the only way to change it.",
// A link from the site, pasted into the chat, arrives as text unless it is this
// project's, whose boards it names become chips (App.tsx, attach).
"A superproto.dev/p/<id>/<name> link they paste is a community project's. `sp fetch " +
"'<link>'` (quoted, for its ? and #) prints the folder holding a copy of it to " +
"read, and in the link " +
"`?canvas=<slug>` is its folder canvases/<slug>, `#<file>` the board <file>.html " +
"in that folder, `#assets/<path>` the picture at that path in it, and " +
"`?doc=<file>` that file at its root.",
community === undefined &&
canvas &&
(dir === undefined || folder !== path.join(boards, canvas)
Expand Down
5 changes: 2 additions & 3 deletions canvas/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import "tldraw/tldraw.css";
import "@tldraw/commenting/commenting.css";
import { installAgentBridge } from "./agentBridge";
import {
sameProject,
targetFromUrl,
tabFromUrl,
windowUrl,
Expand Down Expand Up @@ -1320,9 +1321,7 @@ export default function App() {
const here = new URL(windowUrl(window.location.href));
const targets = hrefs.map((href) => {
if (!URL.canParse(href)) return undefined;
const url = new URL(href);
if (url.origin !== here.origin || url.pathname !== here.pathname)
return undefined;
if (!sameProject(href, here.href)) return undefined;
const tab = tabFromUrl(href);
const name = targetFromUrl(href);
if (tab.kind !== "canvas" || !name) return undefined;
Expand Down
7 changes: 4 additions & 3 deletions canvas/src/CanvasFileShapeUtil.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
useIsEditing,
} from "tldraw";
import { CanvasChromeContext } from "./canvasChrome";
import { canvasIndex } from "./canvasIndex";
import { local } from "./canvasIndex";
import {
CANVAS_FILE_DEFAULT_SIZE,
canvasBoardRef,
Expand Down Expand Up @@ -176,12 +176,13 @@ export class CanvasFileShapeUtil extends BaseBoxShapeUtil<CanvasFileShape> {
}

// An export leaves an iframe blank, so the server draws the board (canvasAttach.tsx does the
// same), for `sp canvas shot` and the person's own export alike. The hosted build has no server.
// same), for `sp canvas shot` and the person's own export alike, a community project's in the app
// included. The hosted build has no server.
// One that cannot be drawn is left out rather than thrown: tldraw waits on every shape's toSvg
// together, so a throw would blank the whole export (App.tsx has its wait).
override async toSvg(shape: CanvasFileShape) {
const ref = canvasBoardRef(shape.props.path);
if (!ref || !canvasIndex().served) return null;
if (!ref || !local()) return null;
const { w, h } = shape.props;
// The server draws at most 4000 a side; a board resized past that is drawn smaller, evenly.
// ponytail: smaller is a narrower viewport, so a board that lays out by width may reflow.
Expand Down
7 changes: 5 additions & 2 deletions canvas/src/CanvasStrip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { docsOf, ownCanvases, pageOf, tabFor, tabUrl } from "./canvasTabs";
import { canvasIndex, LAYOUT_CHANGED } from "./canvasIndex";
import { groundEditable, setGround, useGround } from "./canvasGround";
import { ViewIcon } from "./CanvasTabBar";
import { sheetPageUrl, type CanvasTab } from "./canvasUrl";
import { shareUrl, sheetPageUrl, type CanvasTab } from "./canvasUrl";
import { confirmTrash, openMenu, REVEAL, TRASH } from "./contextMenu";
import { DocModeSwitch } from "./DocTab";
import { FileText, LogoFigma, Plus } from "./geistIcons";
Expand Down Expand Up @@ -201,7 +201,10 @@ export function CanvasStrip() {
className="sp-menu-row"
onClick={() =>
navigator.clipboard.writeText(
new URL(tabUrl({ ...tab, view: target! }), location.href).href,
shareUrl(
new URL(tabUrl({ ...tab, view: target! }), location.href).href,
canvasIndex().title,
),
)
}
>
Expand Down
27 changes: 14 additions & 13 deletions canvas/src/CanvasTabBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
tabUrl,
type ProjectTab,
} from "./canvasTabs";
import type { CanvasTab } from "./canvasUrl";
import { shareUrl, type CanvasTab } from "./canvasUrl";
import type { Working } from "./ChatPanel";
import { askServer, openMenu, REVEAL } from "./contextMenu";
import { Cross, Home, Plus, Users } from "./geistIcons";
Expand Down Expand Up @@ -229,19 +229,20 @@ export function CanvasTabBar(props: {
onClick={() =>
// The project's bare address, which opens its first canvas, not the view left in
// front; an app's canvas is one canvas. A community project's is the site's, since
// this one is localhost.
// this one is localhost (shareUrl).
navigator.clipboard.writeText(
communityId(target) !== undefined
? `https://superproto.dev/p/${communityId(target)}`
: new URL(
target.kind === "project"
? target.url
: tabUrl({
...target,
view: { kind: "canvas", slug: target.slug },
}),
location.href,
).href,
shareUrl(
new URL(
target.kind === "project"
? target.url
: tabUrl({
...target,
view: { kind: "canvas", slug: target.slug },
}),
location.href,
).href,
target.kind === "project" ? target.title : undefined,
),
)
}
>
Expand Down
18 changes: 4 additions & 14 deletions canvas/src/Community.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState, type ComponentType } from "react";
import "./community.css";
import { openInTab } from "./canvasTabs";
import { webUrl } from "./canvasUrl";
import {
ArrowRight,
Box,
Expand Down Expand Up @@ -29,17 +30,6 @@ import {
* its own address (docs/2026-09-25-project-urls.md), and in the app as its tab.
*/

/**
* A project's page on the site, a link worth sending: the app's own address is localhost. The
* id finds it and the name is for people reading the link, as in Figma's file links.
*/
const webUrl = (entry: Entry) => {
const name = entry.name
.toLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, "-")
.replace(/^-|-$/g, "");
return `https://superproto.dev/p/${entry.id}/${name}`;
};
const COMMUNITY = "https://github.com/ReScienceLab/super-prototyping-community";
const SHARE = `${COMMUNITY}#share-a-project`;
const TAKEDOWN = `${COMMUNITY}/issues/new?template=takedown.yml`;
Expand Down Expand Up @@ -560,7 +550,7 @@ export function CommunityPage({ openInApp }: { openInApp?: OpenInApp }) {
{openInApp ? (
<a
className="cm-btn cm-btn--solid cm-btn--md"
href={webUrl(open)}
href={webUrl(open.id, open.name)}
onClick={openInTab((id: string) => {
setOpen(null);
openInApp(id);
Expand All @@ -571,7 +561,7 @@ export function CommunityPage({ openInApp }: { openInApp?: OpenInApp }) {
) : (
<a
className="cm-btn cm-btn--solid cm-btn--md"
href={webUrl(open)}
href={webUrl(open.id, open.name)}
rel="noopener noreferrer"
>
Open <ArrowRight />
Expand All @@ -582,7 +572,7 @@ export function CommunityPage({ openInApp }: { openInApp?: OpenInApp }) {
className="cm-btn cm-btn--ghost cm-btn--md"
onClick={() =>
navigator.clipboard
.writeText(webUrl(open))
.writeText(webUrl(open.id, open.name))
.then(() => setCopied(open.id))
}
>
Expand Down
15 changes: 9 additions & 6 deletions canvas/src/canvasChrome.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ import {
resolveAuthor,
type CommentUser,
} from "./canvasComments";
import { canvasIndex } from "./canvasIndex";
import { canvasIndex, local } from "./canvasIndex";
import {
CANVAS_FILE_SHAPE_TYPE,
type CanvasFileShape,
Expand All @@ -74,7 +74,7 @@ import {
RefreshCounterClockwise,
} from "./geistIcons";
import { asCanvasTarget, shapeUnderPointer } from "./inspectorClicks";
import { urlForSlug, windowUrl, type CanvasTab } from "./canvasUrl";
import { shareUrl, urlForSlug, windowUrl, type CanvasTab } from "./canvasUrl";

/** One dialog, whether the comment tool raised it or the inspector's composer did. */
const COMMENT_USER_DIALOG = "comment-user";
Expand Down Expand Up @@ -193,7 +193,9 @@ export const canvasUiOverrides: TLUiOverrides = {
* address, a heading say, has none to add.
*/
function selectionLinks(editor: Editor) {
const here = windowUrl(window.location.href);
// A community project's links are the site's, wherever it is open (shareUrl); a paste of one
// back into this canvas still finds its board (sameProject).
const here = shareUrl(windowUrl(window.location.href), canvasIndex().title);
return editor.getSelectedShapes().flatMap((shape) => {
if (shape.type === CANVAS_FILE_SHAPE_TYPE) {
const file = readCanvasLibrary()
Expand Down Expand Up @@ -520,9 +522,10 @@ export const canvasChromeComponents: TLComponents = {
currentUserId={chrome.commentUser?.id ?? null}
resolveAuthor={resolveAuthor}
/>
{/* Dev only, like the panel they hand things to. */}
{canvasIndex().served && <CanvasAttachButtons />}
{canvasIndex().served && <CanvasSelectionAttachButton />}
{/* In the app only, like the panel they hand things to: a community project's too,
whose boards its server draws as it draws the project's own (server/projects.ts). */}
{local() && <CanvasAttachButtons />}
{local() && <CanvasSelectionAttachButton />}
{/* Out of the tool as well as the bubble. Escape closes only the bubble and leaves the
next click placing another one, which is not what an accidental comment wants. The
draft is kept either way, so a real comment interrupted here is there next time. */}
Expand Down
5 changes: 2 additions & 3 deletions canvas/src/canvasContent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import {
readCanvasLibrary,
} from "./canvasLibrary";
import { ownCanvases } from "./canvasTabs";
import { tabFromUrl, targetFromUrl, windowUrl } from "./canvasUrl";
import { sameProject, tabFromUrl, targetFromUrl, windowUrl } from "./canvasUrl";

/**
* What a person put on a canvas, as its folder's canvas.json: the page's shapes that the library
Expand Down Expand Up @@ -365,10 +365,9 @@ async function fromLinks(editor: Editor, text: string, point?: VecLike) {
const links = text.trim().split("\n").map((line) => line.trim());
const found = links.map((href) => {
if (!URL.canParse(href)) return undefined;
const url = new URL(href);
const tab = tabFromUrl(href);
const named = targetFromUrl(href);
if (url.origin !== here.origin || url.pathname !== here.pathname) return undefined;
if (!sameProject(href, here.href)) return undefined;
if (tab.kind !== "canvas" || !named) return undefined;
if (named.startsWith("shape:")) {
// tldraw copies only from the page in front, so one on another canvas pastes as a link.
Expand Down
57 changes: 57 additions & 0 deletions canvas/src/canvasUrl.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { describe, expect, it } from "vitest";
import {
canvasPageUrl,
communityIdOf,
sameProject,
shareUrl,
webUrl,
frameUrl,
sheetPageUrl,
tabFromUrl,
Expand Down Expand Up @@ -162,3 +166,56 @@ describe("canvas URLs", () => {
expect(new URL(urlForSlug(href, "")).search).toBe("?other=1");
});
});

describe("community project links", () => {
const id = "d9021861-db8e-4f0f-8476-f55e6695c773";
const site = `https://superproto.dev/p/${id}/spotify-ios`;

it("names a project on the site by id, and by name for people", () => {
expect(webUrl(id, "Spotify iOS")).toBe(site);
expect(webUrl(id)).toBe(`https://superproto.dev/p/${id}/`);
});

it("hands out the site's address wherever a community project is open", () => {
const view = "?canvas=spotify-ios#03-search";
expect(
shareUrl(`http://127.0.0.1:5199/c/${id}/${view}`, "Spotify iOS"),
).toBe(site + view);
expect(
shareUrl(`https://superproto.dev/p/${id}/${view}`, "Spotify iOS"),
).toBe(site + view);
});

it("leaves a local project's address alone, even one whose folder looks like an id", () => {
for (const href of [
"http://127.0.0.1:5199/p/My%20App/?canvas=home#01-home",
`http://127.0.0.1:5199/p/${id}/?canvas=home`,
]) {
expect(communityIdOf(href)).toBeUndefined();
expect(shareUrl(href, "x")).toBe(href);
}
});

it("takes a link off the site as this project's in the app, and not another's", () => {
const here = `http://127.0.0.1:5199/c/${id}/`;
expect(sameProject(`${site}?canvas=a#b`, here)).toBe(true);
expect(sameProject(`https://superproto.dev/p/${id}/?canvas=a`, here)).toBe(
true,
);
expect(
sameProject(
"https://superproto.dev/p/4a4554bb-1d8a-451a-9470-402023d1b1d2/x",
here,
),
).toBe(false);
expect(
sameProject(
"http://127.0.0.1:5199/p/a/?canvas=x",
"http://127.0.0.1:5199/p/a/",
),
).toBe(true);
expect(
sameProject("http://127.0.0.1:5199/p/a/", "http://127.0.0.1:5199/p/b/"),
).toBe(false);
});
});
49 changes: 49 additions & 0 deletions canvas/src/canvasUrl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,3 +134,52 @@ export function urlForTab(href: string, tab: CanvasTab, target?: string) {
url.searchParams.set(BRAND_PARAM, tab.slug);
return url.href;
}

const SITE = "https://superproto.dev";
const ID = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}";

/**
* A community project's address on the site, `/p/<id>/<name>`, as Figma's file links are: the
* id finds it and the name is for people reading the link (docs/2026-09-25-project-urls.md).
*/
export function webUrl(id: string, name = "") {
const slug = name
.toLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, "-")
.replace(/^-|-$/g, "");
return `${SITE}/p/${id}/${slug}`;
}

/**
* The community project an address is of, by its id: the app's `/c/<id>/`, or the site's
* `/p/<id>/…`. Not a `/p/` on this machine, which is a local project's folder, whatever its name.
*/
export function communityIdOf(href: string) {
const url = new URL(href);
const app = new RegExp(`^/c/(${ID})/`).exec(url.pathname)?.[1];
if (app || ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname))
return app;
return new RegExp(`^/p/(${ID})(/|$)`).exec(url.pathname)?.[1];
}

/**
* The address to hand someone for `href`. A community project's is always the site's, with the
* same view and board, whether it is open there or in the app, whose address is localhost and
* opens nowhere else. A local project's is its own: it is on no site.
*/
export function shareUrl(href: string, name?: string) {
const id = communityIdOf(href);
if (!id) return href;
const { search, hash } = new URL(href);
return webUrl(id, name) + search + hash;
}

/** Whether two addresses are of one project: the same page, or the same community project
* wherever each is open, so a link copied off it (shareUrl) still names this one. */
export function sameProject(a: string, b: string) {
const id = communityIdOf(a);
if (id) return id === communityIdOf(b);
const x = new URL(a);
const y = new URL(b);
return x.origin === y.origin && x.pathname === y.pathname;
}
6 changes: 6 additions & 0 deletions docs/2026-09-25-project-urls.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ maps onto it one for one:
moves (AppShell.tsx). An example once used its folder name as its id,
`/p/claude-ios`; since 2026-09-26 every example is a community project with
a UUID like any other (2026-09-26-projects-on-demand.md).
- **A community project has one link, wherever it is open.** In the app it is
at `127.0.0.1:<port>/c/<id>/`, which opens nowhere else, so every Copy link
on it hands out the site's `superproto.dev/p/<id>/<name>` with the same view
and board (`shareUrl`, canvasUrl.ts), and a link off the site pasted back into
the app still names its boards (`sameProject`). A local project's links stay
its own `127.0.0.1` address: it is on no site to link to.
- **One canvas, many projects.** A project's page is the one build every
project shares; only its index differs. The build writes one per project at
`p/<id>/__sp/index.json` (vite.config.ts), naming it the project, so the
Expand Down
Loading
Loading