diff --git a/AGENTS.md b/AGENTS.md index 2555e7ac..5c4e26b0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -91,7 +91,8 @@ Rules inside a canvas folder: - `gen.py` is the only source of truth. The `NN-*.html` boards are its output. Edit the generator and re-run, never the HTML. -- Commit `layout.json`, `icon.png` and `assets/`. `gen.py` inlines the +- Commit `layout.json`, `icon.png`, `thumbnail.png` (drawn by `sp thumbnail`) + and `assets/`. `gen.py` inlines the images in `assets/` as `data:` URIs. - Commit `PRD.md`, the product the folder prototypes, to the `sp-define-product` skill's template. Its Screens table lists the folder's boards. diff --git a/canvas/community.html b/canvas/community.html new file mode 100644 index 00000000..64ff25cf --- /dev/null +++ b/canvas/community.html @@ -0,0 +1,18 @@ + + + + + + + + Community — Super Prototyping + + + +
+ + + diff --git a/canvas/public/_headers b/canvas/public/_headers new file mode 100644 index 00000000..055f90ee --- /dev/null +++ b/canvas/public/_headers @@ -0,0 +1,3 @@ +# Cloudflare Pages: a board runs in an origin of its own, as server/sp.ts serves it (SANDBOX). +/board/* + Content-Security-Policy: sandbox allow-scripts allow-forms allow-popups allow-modals allow-downloads diff --git a/canvas/server/boards.ts b/canvas/server/boards.ts index 1350c27e..c2e4be4a 100644 --- a/canvas/server/boards.ts +++ b/canvas/server/boards.ts @@ -296,6 +296,7 @@ export function boardIndex( ), layout: readJson(path.join(folder, "layout.json")), icon: fs.existsSync(path.join(folder, "icon.png")), + thumbnail: fs.existsSync(path.join(folder, "thumbnail.png")), brand: brandImages(folder), thumbs: [] as string[], assets: assetIndex(folder), diff --git a/canvas/server/projects.test.ts b/canvas/server/projects.test.ts index e4534d28..953cc54f 100644 --- a/canvas/server/projects.test.ts +++ b/canvas/server/projects.test.ts @@ -65,6 +65,11 @@ it("serves every project at its own address and makes new ones", async () => { req.end(body && JSON.stringify(body)); }); try { + // Asked for by a name DNS could point here, a page from anywhere would be same-origin. + const at = (host: string) => ask("/", undefined, { host }); + expect((await at("rebound.example")).status).toBe(403); + expect((await at(`[::1]:${port}`)).status).toBe(302); + expect((await at("app.localhost")).status).toBe(302); // Nothing opened yet: the root is home, and the examples' window. expect(await ask("/")).toMatchObject({ status: 302, @@ -166,15 +171,29 @@ it("serves every project at its own address and makes new ones", async () => { expect(JSON.parse(made.text)).toEqual({ name: "beta", url: "/p/beta/" }); expect(fs.existsSync(path.join(tmp, "projects/beta/canvases"))).toBe(true); expect(fs.existsSync(path.join(tmp, "projects/beta/.claude"))).toBe(false); + const beta = JSON.parse( + fs.readFileSync(path.join(tmp, "projects/beta/project.json"), "utf8"), + ); + expect(beta.format).toBe(1); + expect(beta.id).toMatch(/^[0-9a-f-]{36}$/); + // One a newer app made is not opened, since this one could misread it and write it back. + write("projects/delta/canvases/one/01-a.html", "delta"); + write("projects/delta/project.json", JSON.stringify({ format: 2 })); + expect((await ask("/p/delta/")).status).toBe(409); + fs.rmSync(path.join(tmp, "projects/delta"), { recursive: true }); expect((await ask("/p/beta/__sp/index.json")).status).toBe(200); expect((await ask("/__sp/projects", { name: "beta" })).status).toBe(409); // No name: the first free "Untitled", which its agent names in project.json. for (const url of ["/p/Untitled/", "/p/Untitled%202/"]) - expect(JSON.parse((await ask("/__sp/projects", { name: " " })).text).url).toBe(url); + expect( + JSON.parse((await ask("/__sp/projects", { name: " " })).text).url, + ).toBe(url); write("projects/Untitled/project.json", JSON.stringify({ name: "Gamma" })); const titled = JSON.parse((await ask("/__sp/projects.json")).text); expect(titled.find((p: any) => p.name === "Untitled").title).toBe("Gamma"); - expect(JSON.parse((await ask("/p/Untitled/__sp/index.json")).text).title).toBe("Gamma"); + expect( + JSON.parse((await ask("/p/Untitled/__sp/index.json")).text).title, + ).toBe("Gamma"); expect((await ask("/__sp/projects", { name: "a/b" })).status).toBe(400); // A reference for a clone: into the project's `refs`, once, and only under a plain name. diff --git a/canvas/server/projects.ts b/canvas/server/projects.ts index 5a3cf60b..28c051e2 100644 --- a/canvas/server/projects.ts +++ b/canvas/server/projects.ts @@ -11,11 +11,12 @@ */ import fs from "node:fs"; import type { IncomingMessage, ServerResponse } from "node:http"; +import net from "node:net"; import os from "node:os"; import path from "node:path"; import { pipeline } from "node:stream/promises"; import { createAgentServer } from "./agent.ts"; -import { CANVASES } from "./boards.ts"; +import { CANVASES, readJson } from "./boards.ts"; import { createSpServer, reveal, sameOrigin, trash } from "./sp.ts"; /** @@ -49,6 +50,29 @@ function moveOldBoards(dir: string) { fs.rmSync(path.dirname(old), { recursive: true }); } +/** + * The newest `project.json` format this app reads. It goes up only when an app that reads this one + * would misread a project of the next; a file or key it does not know is ignored and kept, so + * adding one needs no new format. A project with none is format 1. + */ +const PROJECT_FORMAT = 1; + +/** Whether a Host header names this machine by an address or by localhost, which no DNS can move. */ +export function loopbackHost(host: string | undefined) { + if (!host) return false; + let hostname: string; + try { + hostname = new URL(`http://${host}`).hostname; + } catch { + return false; + } + return ( + net.isIP(hostname.replace(/^\[(.*)\]$/, "$1")) !== 0 || + hostname === "localhost" || + hostname.endsWith(".localhost") + ); +} + export function createProjectsServer(options: { /** Where every project is listed from, and where `POST /__sp/projects` makes one. */ projectsDir: string; @@ -111,6 +135,14 @@ export function createProjectsServer(options: { res: ServerResponse, next: () => void, ) => { + // Asked for by an address, not a name that could be anyone's. A site can point its own name at + // 127.0.0.1 once its page is open (DNS rebinding), and then it is same-origin with this server + // and every guard here waves it through. The browser still sends the name it asked for as + // Host, so a name other than localhost is refused. + if (!loopbackHost(req.headers.host)) { + res.statusCode = 403; + return res.end("This server answers only to localhost or an IP address."); + } const url = req.url ?? "/"; const [pathname, query = ""] = url.split(/\?(.*)/s); // The bare root is the home page, and the root with a query is the window on an example @@ -229,6 +261,11 @@ export function createProjectsServer(options: { // here and the examples, is the tree's and shown beside the project's own, so there is // nothing to copy in. fs.mkdirSync(path.join(dir, CANVASES), { recursive: true }); + // Its id is what a package of it is known by, whatever the folder is renamed to. + fs.writeFileSync( + path.join(dir, "project.json"), + `${JSON.stringify({ format: PROJECT_FORMAT, id: crypto.randomUUID() }, null, 2)}\n`, + ); } catch (e) { // A new name does not fix an unwritable Documents, so say what failed. return send( @@ -255,6 +292,14 @@ export function createProjectsServer(options: { res.statusCode = 404; return res.end("no such project"); } + // Made by a newer app, which may keep it in a way this one would misread, and then write back. + const format = (readJson(path.join(dir, "project.json")) as { format?: unknown })?.format; + if (typeof format === "number" && format > PROJECT_FORMAT) { + res.statusCode = 409; + return res.end( + `“${decodeURIComponent(name)}” was made by a newer Super Prototyping. Update the app to open it.`, + ); + } // The url stays stripped for `next`, which is the static app or Vite serving the page. req.url = rest; spFor(dir).handle(req, res, next); diff --git a/canvas/server/sp.test.ts b/canvas/server/sp.test.ts index 2f1b92bc..25760032 100644 --- a/canvas/server/sp.test.ts +++ b/canvas/server/sp.test.ts @@ -46,6 +46,9 @@ it("shows the examples read-only beside the project's canvases", async () => { ]); expect((await ask("/board/an-example/01-a.html")).text).toBe("example"); expect((await ask("/board/shadowed/01-a.html")).text).toBe("mine"); + // Opened at its own address, a board runs in an origin of its own, not the canvas's. + const board = await ask("/board/shadowed/01-a.html"); + expect(board.csp).toMatch(/^sandbox allow-scripts /); const ground = { ground: "#000000" }; expect( @@ -342,6 +345,7 @@ it("serves the project's own files by their absolute path", async () => { ).toEqual({ status: 200, text: "glow", + csp: expect.stringMatching(/^sandbox allow-scripts /), }); expect((await ask(at(path.join(tmp, "secret.html")))).status).toBe(404); // A junction is the link Windows makes without admin rights, and a symlink elsewhere. @@ -406,7 +410,10 @@ it("hands a canvas command to the open canvas page and its answer back", async ( sheet.close(); // A shell cancelled while its command waited: the command is not run for nobody. - const cancelled = ask("/__sp/canvas", { slug: "home", command: { op: "delete" } }); + const cancelled = ask("/__sp/canvas", { + slug: "home", + command: { op: "delete" }, + }); await waiting(); cancelled.drop(); // Sent before the page opens, which is a reload: it goes to the page once it does. @@ -459,13 +466,23 @@ async function serve(options: Parameters[0]) { // `drop` is the caller going away before the answer, as a cancelled shell does. const ask = (url: string, body?: object) => { let req!: http.ClientRequest; - const answered = new Promise<{ status: number; text: string }>((done) => { + const answered = new Promise<{ + status: number; + text: string; + csp?: string | string[]; + }>((done) => { req = http.request( { port, path: url, method: body ? "POST" : "GET" }, (res) => { let text = ""; res.on("data", (chunk) => (text += chunk)); - res.on("end", () => done({ status: res.statusCode!, text })); + res.on("end", () => + done({ + status: res.statusCode!, + text, + csp: res.headers["content-security-policy"], + }), + ); }, ); req.on("error", () => {}); diff --git a/canvas/server/sp.ts b/canvas/server/sp.ts index d8bab39e..8397485e 100644 --- a/canvas/server/sp.ts +++ b/canvas/server/sp.ts @@ -50,6 +50,15 @@ export function sameOrigin(req: IncomingMessage) { return site === undefined || site === "same-origin" || site === "none"; } +/** + * What a board, or any page a project holds, runs under when it is opened at its own address: an + * origin of its own, so its script cannot reach the canvas's storage or its write endpoints, whose + * guard sees it as cross-site. A board is a project's, and a project can be someone else's. + * `public/_headers` gives the hosted build's `/board/*` the same. + */ +const SANDBOX = + "sandbox allow-scripts allow-forms allow-popups allow-modals allow-downloads"; + /** * A canvas's folder: the project's own, else the example of that name. The project's own is what * the scan in boards.ts calls a canvas, a folder with a board or a layout.json in it, so a folder @@ -349,7 +358,7 @@ export function createSpServer(options: { // A board folder's files, at the addresses the index hands the page: `/.html` // is a board as a web page, which the canvas's two "open as a web page" buttons point at; // `/icon.png` and `/assets/brand/**` are the images a page places as shapes - // of their own. The build emits the same paths as files; here they are read off the + // of their own, and `/thumbnail.png` the one the community page shows for it. The build emits the same paths as files; here they are read off the // boards directory per request, so a reload shows the current version. route("/board", (req, res) => { const send = (code: number, message: string) => { @@ -370,7 +379,7 @@ export function createSpServer(options: { const type = parts.length === 2 && parts[1].endsWith(".html") ? "text/html; charset=utf-8" - : parts.length === 2 && parts[1] === "icon.png" + : parts.length === 2 && ["icon.png", "thumbnail.png"].includes(parts[1]) ? "image/png" : parts.length >= 4 && parts[1] === "assets" && parts[2] === "brand" ? IMAGE_MIME[path.extname(parts[parts.length - 1]).toLowerCase()] @@ -390,6 +399,7 @@ export function createSpServer(options: { return send(404, "not a board"); } res.setHeader("Content-Type", type); + res.setHeader("Content-Security-Policy", SANDBOX); res.setHeader("Cache-Control", "no-store"); res.setHeader("Accept-Ranges", "bytes"); // A byte range, which is how a video seeks: a pasted one can be a gigabyte, and without it @@ -467,10 +477,7 @@ export function createSpServer(options: { return res.end("not a file of this project"); } res.setHeader("Content-Type", type); - res.setHeader( - "Content-Security-Policy", - "sandbox allow-scripts allow-forms allow-popups allow-modals allow-downloads", - ); + res.setHeader("Content-Security-Policy", SANDBOX); res.setHeader("Cache-Control", "no-store"); fs.createReadStream(file).pipe(res); }); diff --git a/canvas/src/AppShell.tsx b/canvas/src/AppShell.tsx index 849e82b7..3dd34bc5 100644 --- a/canvas/src/AppShell.tsx +++ b/canvas/src/AppShell.tsx @@ -6,6 +6,7 @@ import { readOpenTabs, tabFor, tabKey, + tabOfExample, tabUrl, withTab, writeOpenTabs, @@ -21,6 +22,7 @@ import { type CanvasAttachDetail, type Working, } from "./ChatPanel"; +import { CommunityPage } from "./Community"; import { HomePage } from "./HomePage"; import { NewProjectDialog, type NewProjectStart } from "./NewProjectDialog"; import { Onboarding } from "./Onboarding"; @@ -42,7 +44,10 @@ declare global { }; /** The canvas's side (App.tsx): brings a tab forward, or says it is another project's; and * attaches the boards and pictures pasted links name, or says one of them names none. */ - spCanvas?: { goTo(tab: ProjectTab): boolean; attach(hrefs: string[]): boolean }; + spCanvas?: { + goTo(tab: ProjectTab): boolean; + attach(hrefs: string[]): boolean; + }; } } @@ -56,7 +61,9 @@ declare global { const openedTab = tabFor(tabFromUrl(location.href)); const opened = /\/home(\.html)?$/.test(location.pathname) || - (!canvasIndex().served && openedTab.kind === "project" && openedTab.view.kind === "canvas") + (!canvasIndex().served && + openedTab.kind === "project" && + openedTab.view.kind === "canvas") ? null : { tab: openedTab, href: frameUrl(location.href) }; @@ -83,7 +90,16 @@ const onboarding = new URLSearchParams(location.search).get("onboarding"); */ export function AppShell() { const chat = useChat(); - const [home, setHome] = useState(opened === null); + /** What is over the frame, if anything: home, or the community (Community.tsx). */ + const [page, setPage] = useState<"home" | "community" | null>( + opened === null ? "home" : null, + ); + /** Whether the frame is behind a page, so no project's canvas is in front. */ + const home = page !== null; + const setHome = (on: boolean) => setPage(on ? "home" : null); + /** Whether the community has its chip on the bar. */ + // ponytail: not among the tabs kept for the next visit; keep it in writeOpenTabs if missed. + const [community, setCommunity] = useState(false); /** What the canvas last said it has in front, and its address; null before one has loaded. */ const [shown, setShown] = useState(opened); /** @@ -92,7 +108,9 @@ export function AppShell() { */ const [tabs, setTabs] = useState(() => { // A build has no project, so a tab left from before it had only examples is gone. - const open = readOpenTabs().filter((tab) => canvasIndex().served || tab.kind === "example"); + const open = readOpenTabs().filter( + (tab) => canvasIndex().served || tab.kind === "example", + ); return opened ? withTab(open, opened.tab) : open; }); /** Every project there is, for the "+" menu and the home page; empty until the server says. */ @@ -130,7 +148,8 @@ export function AppShell() { useEffect(() => { const href = home || !shown - ? new URL(canvasIndex().served ? "/home.html" : "./", location.href).href + ? new URL(canvasIndex().served ? "/home.html" : "./", location.href) + .href : windowUrl(shown.href); if (href !== location.href) history.replaceState(null, "", href); }, [home, shown]); @@ -214,10 +233,13 @@ export function AppShell() { for (const file of start.files) { const query = new URLSearchParams({ name: made.name, file: file.name }); try { - const up = await fetch(new URL(`/__sp/projects/ref?${query}`, location.origin), { - method: "POST", - body: file, - }); + const up = await fetch( + new URL(`/__sp/projects/ref?${query}`, location.origin), + { + method: "POST", + body: file, + }, + ); if (up.ok) names.push(`refs/${file.name}`); else failed.push(`${file.name}: ${await up.text()}`); } catch (error) { @@ -225,7 +247,9 @@ export function AppShell() { } } if (failed.length > 0) - alert(`The project was made, but these could not be copied into it:\n\n${failed.join("\n")}`); + alert( + `The project was made, but these could not be copied into it:\n\n${failed.join("\n")}`, + ); dialog.current!.close(); // Before anything else its agent names the project, when it was left unnamed, into its // project.json, which the bar and the home page show it by (server/sp.ts). Then it makes and @@ -233,7 +257,7 @@ export function AppShell() { // The skill's command still opens the message, since only there is it one. const first = [ name.trim() === "" && - `name this project: write a short name for it as {"name": "…"} in project.json at the project's root (if you cannot tell yet what it is, make that your first question to me)`, + `name this project: add a short name for it as "name" to project.json at the project's root, keeping the keys already there (if you cannot tell yet what it is, make that your first question to me)`, // Empty: the skill says when a board is due, after the product or the measurements. `make the canvas the work goes in and name it: a folder under canvases/ with its "name" in layout.json, so it opens on my screen, and no board in it until the work reaches one`, ].filter(Boolean); @@ -246,7 +270,10 @@ export function AppShell() { : "Ask me which app to clone, and for screenshots or a screen recording of it.", ] : start.define - ? ["/sp-define-product", "Help me work out what this product is, and write PRD.md as we go."] + ? [ + "/sp-define-product", + "Help me work out what this product is, and write PRD.md as we go.", + ] : ["", "Ask me what this project is."]; // What they said about the idea, in their words, for the agent to start from rather than ask. const idea = @@ -255,7 +282,10 @@ export function AppShell() { : ""; starting.current = { url, - text: [skill, `Before anything else, ${first.join(", then ")}. Then: ${ask}${idea}`] + text: [ + skill, + `Before anything else, ${first.join(", then ")}. Then: ${ask}${idea}`, + ] .filter(Boolean) .join(" "), }; @@ -266,11 +296,17 @@ export function AppShell() { // is in front now. const starting = useRef<{ url: string; text: string }>(undefined); useEffect(() => { - if (shown?.tab.kind !== "project" || shown.tab.url !== starting.current?.url) return; + if ( + shown?.tab.kind !== "project" || + shown.tab.url !== starting.current?.url + ) + return; const { text } = starting.current; starting.current = undefined; window.dispatchEvent( - new CustomEvent(CANVAS_ATTACH, { detail: { kind: "send", text } }), + new CustomEvent(CANVAS_ATTACH, { + detail: { kind: "send", text }, + }), ); }, [shown]); const newProject = () => { @@ -289,10 +325,25 @@ export function AppShell() { active={home ? null : (shown?.tab ?? null)} working={working} onHome={() => setHome(true)} + community={ + community + ? { + active: page === "community", + open: () => setPage("community"), + // Closing it in front goes home, where it was opened from. + close: () => { + setCommunity(false); + if (page === "community") setHome(true); + }, + } + : undefined + } goTo={goTo} closeTabs={closeTabs} reload={() => - home ? listProjects() : frame.current!.contentWindow!.location.reload() + page === "home" + ? listProjects() + : page === null && frame.current!.contentWindow!.location.reload() } newProject={served ? newProject : undefined} > @@ -307,7 +358,9 @@ export function AppShell() { // in front (HOME_TAB), nor the index of every kit, since both have an empty slug. A // kit is named by the canvas whose material it shows. A document is no canvas either. canvas={(!home && view?.kind !== "doc" && view?.slug) || undefined} - project={home || shown?.tab.kind !== "project" ? undefined : shown.tab.name} + project={ + home || shown?.tab.kind !== "project" ? undefined : shown.tab.name + } chat={chat} onWorking={setWorking} /> @@ -322,8 +375,20 @@ export function AppShell() { src={opened?.href} style={home ? { visibility: "hidden" } : undefined} /> - {home && ( + {page === "community" && ( + // The home page's scrolling layer: the window is the viewport's height and clips. +
+ goTo(tabOfExample(slug, tabs))} + /> +
+ )} + {page === "home" && ( { + setCommunity(true); + setPage("community"); + }} projects={projects} tabs={tabs} goTo={goTo} diff --git a/canvas/src/BoardsSheet.tsx b/canvas/src/BoardsSheet.tsx index c790330b..39dcb7e0 100644 --- a/canvas/src/BoardsSheet.tsx +++ b/canvas/src/BoardsSheet.tsx @@ -6,7 +6,8 @@ import { brandPageUrl, canvasPageUrl } from "./canvasUrl"; /** The extension that reads a page, and the Figma plugin that the extension can hand off to. */ const H2D_EXTENSION = "https://chromewebstore.google.com/detail/htmltodesign/ldnheaepmnmbjjjahokphckbpgciiaed"; -const H2D_PLUGIN = "https://www.figma.com/community/plugin/1159123024924461424/html-to-design"; +const H2D_PLUGIN = + "https://www.figma.com/community/plugin/1159123024924461424/html-to-design"; /** * One canvas page's boards, each at its own size, in one scrolling document. The page behind @@ -14,8 +15,8 @@ const H2D_PLUGIN = "https://www.figma.com/community/plugin/1159123024924461424/h * * Each board is an iframe pointed at that board's own address rather than inlined, because a * board is a whole document — its own doctype, its own reset, its own fonts — and forty of them - * flattened into one would be forty stylesheets fighting. The frames are same-origin, so a board - * behaves here exactly as it does in a tab of its own. + * flattened into one would be forty stylesheets fighting. The server sandboxes each in an origin + * of its own (sp.ts, SANDBOX), so a board behaves here exactly as it does in a tab of its own. */ export function BoardsSheet({ slug }: { slug: string }) { const rows = sheetRows(slug); @@ -47,10 +48,11 @@ export function BoardsSheet({ slug }: { slug: string }) {
  1. - Install the browser extension. The extension rather than the Figma plugin - alone, because the plugin fetches a public address from Figma's servers and a - canvas on localhost is not one — the extension reads the page from inside the - browser that already has it open. + Install the browser extension. The extension rather than + the Figma plugin alone, because the plugin fetches a public + address from Figma's servers and a canvas on localhost is not one + — the extension reads the page from inside the browser that + already has it open. {/* The one thing on this page that has to be done before anything else works, so it is a button and not the third link in a paragraph. */} Add html.to.design to your browser - Free · Chrome, Edge, Brave, Arc and other Chromium browsers + + Free · Chrome, Edge, Brave, Arc and other Chromium browsers + ↗ @@ -83,16 +87,18 @@ export function BoardsSheet({ slug }: { slug: string }) {
  2. - Capture this page. Click the extension's icon while this tab is in front, - leave the viewport on Browser, and press Capture Current Page. It - reads every board below at the size it ships at, rather than the zoomed-out - thumbnail the canvas shows. + Capture this page. Click the extension's icon while this + tab is in front, leave the viewport on Browser, and press{" "} + Capture Current Page. It reads every board below at the + size it ships at, rather than the zoomed-out thumbnail the canvas + shows.
  3. - Paste it into Figma. Pick Copy to clipboard and press ⌘V in a Figma - file; that route needs no plugin at all. The plugin is for the other two routes — - sending the capture straight over, or opening a saved .h2d file. - Either way the boards arrive as editable layers, not as images. + Paste it into Figma. Pick Copy to clipboard and + press ⌘V in a Figma file; that route needs no plugin at all. The + plugin is for the other two routes — sending the capture straight + over, or opening a saved .h2d file. Either way the + boards arrive as editable layers, not as images. shape.type === CANVAS_LINK_SHAPE_TYPE && shape.isLocked, + filter: (shape) => + shape.type === CANVAS_LINK_SHAPE_TYPE && shape.isLocked, }) as CanvasLinkShape | undefined; let pressed: CanvasLinkShape | undefined; @@ -270,7 +271,10 @@ export class CanvasLinkShapeUtil extends BaseBoxShapeUtil { override onClick(shape: CanvasLinkShape) { if (shape.props.url) { - window.open(shape.props.url, "_blank", "noopener,noreferrer"); + // A web address only: a layout.json can come from someone else's project, and a + // `javascript:` one would run in the canvas. + if (/^https?:/i.test(shape.props.url)) + window.open(shape.props.url, "_blank", "noopener,noreferrer"); return; } const page = this.editor diff --git a/canvas/src/CanvasTabBar.tsx b/canvas/src/CanvasTabBar.tsx index 8a53abfa..64846628 100644 --- a/canvas/src/CanvasTabBar.tsx +++ b/canvas/src/CanvasTabBar.tsx @@ -17,7 +17,7 @@ import { import type { CanvasTab } from "./canvasUrl"; import type { Working } from "./ChatPanel"; import { askServer, openMenu, REVEAL } from "./contextMenu"; -import { Cross, Home, Plus } from "./geistIcons"; +import { Cross, Home, Plus, Users } from "./geistIcons"; /** * The bar across the top of the window: the agent's button, which AppShell.tsx hands in as @@ -103,6 +103,8 @@ export function CanvasTabBar(props: { /** What the agent is writing to: a project behind the one in front has a sheen cross its name while it is. */ working: Working; onHome: () => void; + /** The community's chip, while it is open: opened from the home page, and never a project's. */ + community?: { active: boolean; open: () => void; close: () => void }; goTo: (tab: ProjectTab) => void; /** Takes these off the bar, landing on a neighbour when the one in front goes. */ closeTabs: (tabs: ProjectTab[]) => void; @@ -127,13 +129,37 @@ export function CanvasTabBar(props: { onContextMenu={(event) => openMenu(event, menu, () => setTarget("home")) } - aria-current={active ? undefined : "page"} + aria-current={active || props.community?.active ? undefined : "page"} title="Home" onClick={props.onHome} >
    + {props.community && ( + + + + + )} {tabs.map((tab) => ( FAMILIES.find(([k]) => k === f)![1]; + +/** Picked by hand. A slug this build does not have is left out. */ +const COLLECTIONS = [ + { + id: "ai", + title: "AI assistants", + desc: "Chat, ask, get things done.", + slugs: ["claude-ios", "chatgpt-ios", "perplexity-ios", "grok-ios"], + }, + { + id: "social", + title: "Social feeds", + desc: "Scroll, post, connect.", + slugs: ["instagram-ios", "x-ios", "tiktok-ios", "substack-ios"], + }, + { + id: "apple", + title: "Apple's own", + desc: "The apps the phone comes with.", + slugs: [ + "apple-wallet", + "apple-photos", + "apple-calendar", + "apple-settings", + "apple-app-store", + "apple-home-lock", + ], + }, + { + id: "work", + title: "Tools", + desc: "Notes, launchers, events.", + slugs: ["notion-ios", "raycast-ios", "snapaction-ios", "luma-ios"], + }, +]; + +interface Entry { + /** An example's slug, or a shared project's id. */ + slug: string; + name: string; + boards: number; + family: Family; + updated: number; + author: string; + contributors: string[]; + icon?: string; + /** Its thumbnail.png, as `sp pack -o` or `sp thumbnail` drew it. */ + thumbnail: string; + /** A shared project's folder on GitHub. */ + source?: string; +} + +/** index.json, as the community repo's CI writes it (.github/community.py there). */ +interface Index { + projects: { + id: string; + name: string; + author: { login: string }; + contributors: { login: string }[]; + boards: number; + device: string; + thumbnail: string; + icon: string | null; + updated: string; + }[]; +} + +const shared = (index: Index): Entry[] => + index.projects.map((p) => ({ + slug: p.id, + name: p.name, + boards: p.boards, + family: FAMILIES.some(([k]) => k === p.device) + ? (p.device as Family) + : "web", + updated: Date.parse(p.updated), + author: p.author.login, + contributors: p.contributors.map((c) => c.login), + icon: p.icon ? RAW + p.icon : undefined, + thumbnail: RAW + p.thumbnail, + source: `${COMMUNITY}/tree/main/projects/${p.id}`, + })); + +function examples(): Entry[] { + return canvasIndex() + .boards.filter((b) => b.example && b.icon && b.thumbnail) + .map((b) => { + const cover = projectCover([b])!; + return { + slug: b.slug, + name: shortName(b.slug), + boards: b.html.length, + // The phone artboard every clone draws on; anything wider is a page. + family: + cover.w === CANVAS_FILE_DEFAULT_SIZE.w && + cover.h === CANVAS_FILE_DEFAULT_SIZE.h + ? "iphone" + : "web", + thumbnail: canvasThumbnailUrl(b.slug)!, + updated: b.updated, + author: EXAMPLES_BY, + contributors: [], + icon: canvasIconUrl(b.slug), + }; + }); +} + +const plural = (n: number) => `${n} board${n === 1 ? "" : "s"}`; +const avatar = (login: string) => `https://github.com/${login}.png?size=64`; + +/** A project's thumbnail.png, at the Open Graph image's 1200:630 it is drawn at. */ +function Thumb({ entry }: { entry: Entry }) { + return ( +
    + +
    + ); +} + +function Card({ entry, onOpen }: { entry: Entry; onOpen: () => void }) { + return ( +
  4. + +
  5. + ); +} + +/** + * @param openExample The app's: opens a project as its tab. Without it, as on the site, Open is + * a link to the demo canvas. + */ +export function CommunityPage({ + openExample, +}: { + openExample?: (slug: string) => void; +}) { + const [all, setAll] = useState(examples); + const [family, setFamily] = useState("all"); + const [collection, setCollection] = useState(null); + const [q, setQ] = useState(""); + const [open, setOpen] = useState(null); + /** Which project's link was copied, so another one opened says Copy again. */ + const [copied, setCopied] = useState(null); + const search = useRef(null); + const tabs = useRef(null); + const dialog = useRef(null); + + const collections = COLLECTIONS.map((c) => ({ + ...c, + entries: c.slugs + .map((s) => all.find((e) => e.slug === s)) + .filter((e) => e !== undefined), + })).filter((c) => c.entries.length > 0); + const col = collections.find((c) => c.id === collection); + const needle = q.trim().toLowerCase(); + const shown = all.filter( + (e) => + (family === "all" || e.family === family) && + (!col || col.slugs.includes(e.slug)) && + (!needle || + [ + e.name, + e.author, + familyName(e.family), + ...collections + .filter((c) => c.slugs.includes(e.slug)) + .map((c) => c.title), + ].some((t) => t.toLowerCase().includes(needle))), + ); + + // "/" is search, as on the site this was drawn after, unless something is being typed into. + useEffect(() => { + const onKey = (event: KeyboardEvent) => { + const typing = (event.target as Element).closest?.( + "input, textarea, [contenteditable], dialog", + ); + if (event.key === "/" && !typing) { + event.preventDefault(); + search.current?.focus(); + } + }; + addEventListener("keydown", onKey); + return () => removeEventListener("keydown", onKey); + }, []); + + // Offline, or with GitHub down, the page lists the examples alone. + useEffect(() => { + fetch(`${RAW}index.json`) + .then((r) => (r.ok ? r.json() : Promise.reject(r.status))) + .then((index: Index) => setAll([...shared(index), ...examples()])) + .catch(() => {}); + }, []); + + useEffect(() => { + if (open && !dialog.current!.open) dialog.current!.showModal(); + if (!open && dialog.current!.open) dialog.current!.close(); + }, [open]); + + const pickCollection = (id: string) => { + const same = collection === id; + setCollection(same ? null : id); + setFamily("all"); + setOpen(null); + if (!same) + tabs.current!.scrollIntoView({ block: "start", behavior: "smooth" }); + }; + + return ( +
    +
    +

    + Discover community-made real app clones, +
    + device mockups and{" "} + editable boards +

    +
    { + event.preventDefault(); + tabs.current!.scrollIntoView({ + block: "start", + behavior: "smooth", + }); + }} + > + + setQ(event.target.value)} + onKeyDown={(event) => event.key === "Escape" && setQ("")} + /> + {q ? ( + + ) : ( + / + )} + +
    + +
    + {FAMILIES.map(([key, label, Icon]) => { + const n = + key === "all" + ? all.length + : all.filter((e) => e.family === key).length; + return ( + + ); + })} +
    + {col && ( +
    + Collection + + {col.title} + + +
    + )} +
    + +
    +
    Curated collections
    +

    Explore by theme

    +
    + {collections.map((c) => ( + + ))} +
    +
    + +
    +
    +
    + +
    +
    + Share what you made + + Ask your agent to share a project to the community. It opens a + pull request, and it shows up here once merged. + +
    +
    + + How to share + +
    + +
    + + Not affiliated with the companies whose apps are shown here. To have + one removed,{" "} + + open an issue + + . + + + {all.length} {all.length === 1 ? "project" : "projects"} + +
    + + { + event.preventDefault(); + setOpen(null); + }} + onClick={(event) => event.target === dialog.current && setOpen(null)} + > + {open && ( +
    +
    + +
    +
    +
    + {open.icon && } +
    +

    {open.name}

    + + + {open.author} + + {open.contributors.map((c) => ( + + {c} + + ))} +
    +
    +
    +
    + Boards + {open.boards} +
    +
    + Device + {familyName(open.family)} +
    +
    + {collections.some((c) => c.slugs.includes(open.slug)) && ( + <> +
    In collections
    +
    + {collections + .filter((c) => c.slugs.includes(open.slug)) + .map((c) => ( + + ))} +
    + + )} +
    + {open.source ? ( + + View on GitHub + + ) : ( + { + setOpen(null); + openExample(slug); + }, open.slug) + } + > + Open + + )} + +
    +
    + +
    + )} +
    +
    + ); +} diff --git a/canvas/src/HomePage.tsx b/canvas/src/HomePage.tsx index dffe96f1..81dcabdf 100644 --- a/canvas/src/HomePage.tsx +++ b/canvas/src/HomePage.tsx @@ -26,7 +26,7 @@ import { setProjectCover, TRASH, } from "./contextMenu"; -import { FolderPlus, LogoDiscord, LogoGithub, Plus } from "./geistIcons"; +import { FolderPlus, LogoDiscord, LogoGithub, Plus, Users } from "./geistIcons"; type Sort = "edited" | "name" | "boards"; /** A card's right-click: the address it links to, the tab it opens, and its project if it is one. */ @@ -62,14 +62,76 @@ function ago(ms: number) { return "just now"; } +/** + * A cover at `scale`, placed at `place` in its stage. A board is a picture of it, `/__sp/shoot`'s, + * which the server draws once per edit, so a page of forty cards is forty images rather than forty + * documents. Where there is nothing to draw it, a build with no server or a machine without refkit, + * it is the board itself in a frame, sandboxed because a thumbnail has nothing to run. An image is + * the file itself. + */ +function CoverPicture(props: { + cover: Cover; + /** The address its files are under: the project's, or this page's for an example. */ + base: string; + /** When it was last edited, so a board written since is shot again. */ + updated: number; + title: string; + scale: number; + place: { left: number; top: number }; +}) { + const { cover, base, scale, place } = props; + const [live, setLive] = useState(!canvasIndex().served); + const file = `${base}board/${encodeURI(cover.path)}`; + const board = cover.path.endsWith(".html"); + return board && live ? ( +