Skip to content

Brand kit: colours, fonts and logos that follow every design - #7

Open
pallaoro wants to merge 1 commit into
mainfrom
feat/brand-kit
Open

Brand kit: colours, fonts and logos that follow every design#7
pallaoro wants to merge 1 commit into
mainfrom
feat/brand-kit

Conversation

@pallaoro

@pallaoro pallaoro commented Sep 3, 2026

Copy link
Copy Markdown
Member

Why this one

Picked by demand, not taste. Three sources agreed:

  • Ahrefs. canva brand kit runs 3.2K US / 5.8K global searches a month at KD 18 — roughly 3x any other feature term in this space. For comparison: canva bulk create 1.0K, bulk create canva 600, social media image resizer 100, open source canva alternative 70, canva magic resize 60. (self hosted design tool, embeddable design editor, design editor api are not indexed at all.)
  • X, last 6 months. The loudest theme is paywall lock-in, and Brand Kit is the headline Pro upsell. The concrete asks: locked colours/fonts/logo across every output, bulk-apply a brand to an existing template, hand a brand system to a client without rebuilding it. The sharpest pain is portability — "you cannot transfer a brand kit to another account"; offboarding is "send it piece by piece" or rebuild.
  • Polotno Studio, the closest embeddable-editor competitor, puts Brand Kit third in its left rail, above Text — and gates it behind sign-in.

OpenDesign is self-hosted, so the lock-in half is a gap we can simply close.

There is also a gap in our own product: clawnify.json already asks the deployer for "Brand colors, template types you need, custom fonts" and the app had nowhere to put that answer.

What this adds

  • Brand section in the left rail — palette, heading + body font, logos.
  • Brand swatches wherever a colour is chosen — text fill, shape fill, page background. Staying on brand costs one click at the moment of picking, not a trip to a settings page.
  • Apply to this page — re-fonts the text and recolours the design in a single undoable step.
  • Export / Import as a plain .brandkit.json that is genuinely self-contained.
  • GET/POST/PUT/DELETE /api/brand-kits, validated at the boundary (hex-only colours, length caps).

Two non-obvious decisions

Recolouring maps by rank in luminance, not by nearest colour. Nearest-colour is the obvious approach and it is wrong: a dark background and its white heading can snap to the same swatch and the design becomes unreadable. Sorting the design's distinct colours darkest-to-lightest and the brand's the same way, then mapping position to position, means the darkest thing in a design stays the darkest thing in it. Contrast survives a palette swap.

Logos travel as bytes, not as links. putUpload returns an install-relative path (/api/uploads/x.svg), so a naive export ships colours and fonts that work and logos that 404 on arrival — which would make the portability claim in the README false. A kit therefore leaves with its logos inlined as data URIs and arrives by moving those bytes into the receiving install's own storage, so stored values stay short paths and canvas JSON stays small. (Same shape Polotno uses for uploaded assets: inline by default, hosted when a store exists.)

Kits are a list rather than one settings row because an agency running a single install needs one kit per client — and because Import must not destroy the kit you already have.

Verified running, not just building

tsc --noEmit is clean for every file this PR touches, vite build passes, and the feature was driven end to end in a browser against local D1 + R2.

API — create/list/update/delete all work; colors: ["red"] → 400, empty name → 400, unknown id → 404.

Apply — applied the kit #0B3D2E / #F5F5F0 / #C9A227 to the Quote Card template. Persisted canvas JSON afterwards:

object before after
Rect fill #1a1a2e (darkest) #0B3D2E (darkest brand)
Textbox 48px fill / font #ffffff (lightest) / Playfair #F5F5F0 (lightest brand) / Playfair Display (heading, 48 ≥ 32)
Textbox 24px fill / font #a0a0b0 (mid) / Inter #C9A227 (mid brand) / Raleway (body, 24 < 32)

White-on-dark stayed light-on-dark. One Cmd+Z restored the original: the whole apply is a single history entry.

Portability round trip — uploaded a logo, which stored as /api/uploads/1788441353843_ado6ry.svg (37 chars). Exported: the file carries data:image/svg+xml;base64,… (1118 chars), nothing pointing back at this install. Re-imported: the logo landed at a different path, /api/uploads/1788441390866_vn7jl2.svg, and both URLs serve HTTP 200. The bytes moved. A junk JSON file is refused with "That file is not an OpenDesign brand kit."

Deliberately not in this PR

  • Apply across all pages. It looks like a trivial loop (canvasMapRef already holds every page and this file iterates it twice), but undo keys off activeCanvasIdRef only — so an all-pages apply would leave every non-active page silently un-undoable, and autosave would persist the damage. Doing it properly needs a history group spanning canvases. Related: the colour map is computed per page, so a multi-page carousel could map the same source colour differently on different pages. Both belong with that work.
  • Custom font upload. Canva and Adobe Express both gate it behind paid tiers and neither open-source alternative I checked implements a brand kit at all, so it is a real differentiator — but it needs the FontFaceload()document.fonts.add()then build the Textbox dance, or Fabric caches wrong glyph widths. The schema already accepts any font string (heading_font TEXT, z.string().max(60)), so adding it later needs no migration.

Notes for the reviewer

Two pre-existing problems I hit and did not fix, since neither is mine:

  1. The Worker cannot boot on mainuploads.ts calls fileURLToPath at module scope. That is what fix(uploads): store images in R2 so the Worker can boot #4 fixes. To verify at runtime I merged fix(uploads): store images in R2 so the Worker can boot #4 in locally, tested, then dropped the merge; this branch is clean off main. Logo upload needs fix(uploads): store images in R2 so the Worker can boot #4 to have landed.
  2. pnpm run build fails on pnpm 11pnpm-workspace.yaml still ships the literal placeholder allowBuilds: { canvas: set this to true or false, … }, so esbuild/workerd never build. Worth a one-line fix in its own PR.

Pre-existing tsc errors in canvas-area.tsx and uploads.ts (missing @types/node / workers types) are untouched.

Brand consistency is the single most-wanted thing around design editors and
the one most reliably locked behind a paywall. "canva brand kit" runs ~3.2K
US / 5.8K global searches a month — roughly three times any other feature
term in this space — and the recurring complaint is not that the feature is
missing, it is that the kit is trapped: it cannot be moved to another
account, so offboarding means rebuilding it by hand.

OpenDesign is self-hosted, so it can simply not do that. This adds:

- A Brand section in the left rail: palette, heading/body font, logos.
- Brand swatches wherever a colour is chosen (text fill, shape fill, page
  background), so staying on brand costs one click at the moment of picking.
- "Apply to this page", which re-fonts the text and recolours the design in
  one undoable step.
- Export / Import as a plain .brandkit.json file. A kit moves between
  installs, or to a client, with no rebuild.

Recolouring maps by rank in luminance rather than nearest colour: the
darkest thing in a design stays the darkest thing in it, so contrast
survives a palette swap instead of collapsing a background and its heading
onto the same swatch.

Kits are a list rather than one settings row because an agency running a
single install needs one kit per client.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant