Skip to content
Open
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
22 changes: 20 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,30 @@ Unlike Canva or Adobe Express, this runs entirely on your own infrastructure. No
- **Shapes** — rectangles, circles, triangles, lines with fill, stroke, border radius
- **Image uploads** — drag-and-drop or click to upload, place on canvas
- **Backgrounds** — solid colors, gradients, uploaded images
- **Canvas sizes** — LinkedIn Square (1080x1080), LinkedIn Landscape (1200x627), LinkedIn Portrait (1200x1500), Instagram Story (1080x1920)
- **Canvas sizes** — LinkedIn Square (1080x1080), LinkedIn Carousel (1080x1350), LinkedIn Landscape (1200x627), LinkedIn Portrait (1200x1500), Instagram Story (1080x1920)
- **Undo/Redo** — full history with keyboard shortcuts (Cmd+Z / Cmd+Shift+Z)
- **2x PNG export** — crisp high-resolution output for social media
- **Carousel PDF export**: every page in one multi-page PDF, the only format LinkedIn accepts for a swipeable carousel
- **2x PNG export**: this page or every page, at exactly 2x the design size on any display
- **Auto-save** — designs persist to SQLite with debounced saves
- **Dual-mode UI** — human-optimized + AI-agent-optimized (`?agent=true`)

## Carousels

A swipeable carousel on LinkedIn is a **document post**. LinkedIn's own help pages
document the format (PDF, DOC/DOCX or PPT/PPTX, up to 300 pages and 100 MB), and the
short-lived native multi-image carousel it shipped in 2022 was removed in June 2023,
with existing carousel posts deleted that December. Uploading a document is the way.

OpenDesign builds one end to end: add a page per slide, then **Export → Carousel PDF**
writes them all into a single file, in canvas order, at the slide's own aspect ratio.
Upload that PDF to LinkedIn as a document.

Format and limits per [LinkedIn Help: upload and share documents](https://www.linkedin.com/help/linkedin/answer/a518909).
Note that *Carousel Ads* is a separate paid ads product, unrelated to this.

Use the **LinkedIn Carousel** canvas size (1080x1350, 4:5). It takes up the most room
on a phone screen. Exports are assembled in the browser, so nothing leaves the machine.

## Quickstart

```bash
Expand Down
5 changes: 4 additions & 1 deletion agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,15 @@ A design editor for creating professional social media graphics, especially Link
- Pre-built LinkedIn post templates (Quote Card, Stats Highlight, Announcement, Tips List, Profile Card, Minimal Text)
- Text editing with Google Fonts (Inter, Montserrat, Playfair Display)
- Image uploads and placement
- Multiple canvas sizes (1080x1080 square, 1200x627 landscape)
- Multiple canvas sizes (1080x1080 square, 1080x1350 carousel, 1200x627 landscape)
- Multi-page designs exported as one PDF, which is how a LinkedIn carousel is published
- 2x PNG export, one page or all of them
- Save and manage multiple designs

## When to use this template
Use this template when the user wants to:
- Create social media post images or graphics
- Design LinkedIn posts, quote cards, or announcement banners
- Build a LinkedIn or Instagram carousel and export it as a PDF
- Build a simple graphic design tool
- Create branded visual content
4 changes: 2 additions & 2 deletions clawnify.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@
"$schema": "https://app.clawnify.com/schema/v1/clawnify.json",
"version": 1,
"name": "OpenDesign",
"description": "Design editor for creating professional social media graphics, LinkedIn posts, and visual content with a canvas-based UI, templates, multi-page support, image uploads, and text/shape tools. Like Canva. Best for graphic design tools, social media post creators, banner makers, or any visual content editor.",
"description": "Design editor for creating professional social media graphics, LinkedIn posts, carousels, and visual content with a canvas-based UI, templates, multi-page support, image uploads, and text/shape tools. Exports multi-page designs as a LinkedIn-ready carousel PDF, or as 2x PNGs. Like Canva. Best for graphic design tools, social media post creators, carousel makers, banner makers, or any visual content editor.",
"icon": "icon.svg",
"screenshot": "https://github.com/user-attachments/assets/55fcc0aa-4606-4f80-b56a-7734d7673914",
"tags": ["design", "graphics", "social-media", "canvas"],
"tags": ["design", "graphics", "social-media", "canvas", "carousel", "linkedin"],

"app": {
"framework": "preact+hono",
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"@tailwindcss/vite": "^4.2.2",
"fabric": "^6.0.0",
"hono": "^4.6.0",
"jspdf": "^4.2.1",
"lucide-preact": "^0.575.0",
"preact": "^10.25.0",
"tailwindcss": "^4.2.2",
Expand Down
53 changes: 53 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
# jsPDF's optional extras (.svg()/.html() support) are unused here, and canvg
# drags in node-canvas, a native build that would make `pnpm install` need
# cairo on every machine that clones this template.
ignoredOptionalDependencies:
- canvg
- core-js
- dompurify
- html2canvas
allowBuilds:
canvas: set this to true or false
esbuild: set this to true or false
Expand Down
77 changes: 69 additions & 8 deletions src/client/components/toolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ import {
Save,
ChevronDown,
Home,
FileText,
Image as ImageIcon,
Images,
} from "lucide-preact";
import { useEditor, CANVAS_SIZES } from "../context";

Expand All @@ -27,6 +30,10 @@ export function Toolbar() {
zoomIn,
zoomOut,
exportPNG,
exportAllPNG,
exportPDF,
exporting,
pages,
saveDesign,
saving,
activeDesign,
Expand All @@ -35,6 +42,7 @@ export function Toolbar() {
} = useEditor();

const [showSizeDropdown, setShowSizeDropdown] = useState(false);
const [showExportMenu, setShowExportMenu] = useState(false);
const [editingName, setEditingName] = useState(false);
const [nameValue, setNameValue] = useState("");

Expand All @@ -56,6 +64,14 @@ export function Toolbar() {
setEditingName(false);
};

const designName = activeDesign?.name ?? "design";
const pageIds = pages.map((p) => p.id);

const runExport = (fn: () => void) => {
setShowExportMenu(false);
fn();
};

return (
<div class="flex items-center justify-between px-3 py-1.5 bg-white border-b border-zinc-200 shrink-0">
{/* Left: Home + Design name + Canvas size */}
Expand Down Expand Up @@ -176,14 +192,59 @@ export function Toolbar() {

<div class="w-px h-5 bg-zinc-300 mx-1" />

<button
class="inline-flex items-center gap-1.5 px-3 py-1 rounded-md text-[11px] font-semibold border border-zinc-300 cursor-pointer transition-all bg-transparent text-zinc-600 hover:bg-zinc-100 hover:text-zinc-900"
onClick={exportPNG}
title="Export as PNG"
>
<Download size={13} />
Export
</button>
<div class="relative">
<button
class="inline-flex items-center gap-1.5 px-3 py-1 rounded-md text-[11px] font-semibold border border-zinc-300 cursor-pointer transition-all bg-transparent text-zinc-600 hover:bg-zinc-100 hover:text-zinc-900 disabled:opacity-50"
onClick={() => setShowExportMenu(!showExportMenu)}
disabled={exporting || !activeDesign}
title="Export this design"
>
{exporting ? <span class="spinner" /> : <Download size={13} />}
{exporting ? "Preparing..." : "Export"}
<ChevronDown size={12} />
</button>
{showExportMenu && (
<>
<div class="fixed inset-0 z-10" onClick={() => setShowExportMenu(false)} />
<div class="absolute top-full right-0 mt-1 bg-white border border-zinc-300 rounded-lg shadow-xl z-20 min-w-[230px] py-1">
<button
class="w-full flex items-start gap-2.5 text-left px-3 py-2 text-xs text-zinc-600 bg-transparent border-none cursor-pointer transition-colors hover:bg-zinc-100"
onClick={() => runExport(() => exportPDF(pageIds, designName))}
>
<FileText size={14} class="mt-0.5 shrink-0 text-accent" />
<span>
<span class="block font-medium text-zinc-900">Carousel PDF</span>
<span class="block text-[10px] text-zinc-400">
All {pages.length} {pages.length === 1 ? "page" : "pages"}, ready for LinkedIn
</span>
</span>
</button>
<button
class="w-full flex items-start gap-2.5 text-left px-3 py-2 text-xs text-zinc-600 bg-transparent border-none cursor-pointer transition-colors hover:bg-zinc-100"
onClick={() => runExport(() => exportPNG(designName))}
>
<ImageIcon size={14} class="mt-0.5 shrink-0 text-zinc-400" />
<span>
<span class="block font-medium text-zinc-900">This page</span>
<span class="block text-[10px] text-zinc-400">PNG at 2x</span>
</span>
</button>
{pages.length > 1 && (
<button
class="w-full flex items-start gap-2.5 text-left px-3 py-2 text-xs text-zinc-600 bg-transparent border-none cursor-pointer transition-colors hover:bg-zinc-100"
onClick={() => runExport(() => exportAllPNG(pageIds, designName))}
>
<Images size={14} class="mt-0.5 shrink-0 text-zinc-400" />
<span>
<span class="block font-medium text-zinc-900">All pages</span>
<span class="block text-[10px] text-zinc-400">{pages.length} separate PNGs at 2x</span>
</span>
</button>
)}
</div>
</>
)}
</div>
<button
class="inline-flex items-center gap-1.5 px-3.5 py-1 rounded-md text-[11px] font-semibold border-none cursor-pointer transition-all bg-accent text-white hover:bg-accent-hover disabled:opacity-50"
onClick={saveDesign}
Expand Down
7 changes: 6 additions & 1 deletion src/client/context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ export interface CanvasSize {

export const CANVAS_SIZES: CanvasSize[] = [
{ label: "LinkedIn Square", width: 1080, height: 1080 },
// LinkedIn's recommended carousel slide. 4:5 fills the most phone screen.
{ label: "LinkedIn Carousel", width: 1080, height: 1350 },
{ label: "LinkedIn Landscape", width: 1200, height: 627 },
{ label: "LinkedIn Portrait", width: 1200, height: 1500 },
{ label: "Instagram Story", width: 1080, height: 1920 },
Expand Down Expand Up @@ -46,7 +48,10 @@ export interface EditorContextValue {
zoomToFit: () => void;
zoomIn: () => void;
zoomOut: () => void;
exportPNG: () => void;
exportPNG: (designName: string) => void;
exportAllPNG: (pageIds: string[], designName: string) => void;
exportPDF: (pageIds: string[], designName: string) => Promise<void>;
exporting: boolean;
getCanvasJSON: () => string;
getCanvasJSONForPage: (pageId: string) => string;
loadTemplate: (template: Template) => void;
Expand Down
Loading