Brand kit: colours, fonts and logos that follow every design - #7
Open
pallaoro wants to merge 1 commit into
Open
Conversation
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.
pallaoro
force-pushed
the
feat/brand-kit
branch
from
September 3, 2026 13:17
9925776 to
85abcf7
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why this one
Picked by demand, not taste. Three sources agreed:
canva brand kitruns 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 create1.0K,bulk create canva600,social media image resizer100,open source canva alternative70,canva magic resize60. (self hosted design tool,embeddable design editor,design editor apiare not indexed at all.)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.jsonalready asks the deployer for "Brand colors, template types you need, custom fonts" and the app had nowhere to put that answer.What this adds
.brandkit.jsonthat 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.
putUploadreturns 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 --noEmitis clean for every file this PR touches,vite buildpasses, 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 / #C9A227to the Quote Card template. Persisted canvas JSON afterwards:Rectfill#1a1a2e(darkest)#0B3D2E(darkest brand)Textbox48px fill / font#ffffff(lightest) / Playfair#F5F5F0(lightest brand) / Playfair Display (heading, 48 ≥ 32)Textbox24px 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 carriesdata: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
canvasMapRefalready holds every page and this file iterates it twice), butundokeys offactiveCanvasIdRefonly — 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.FontFace→load()→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:
main—uploads.tscallsfileURLToPathat 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 offmain. Logo upload needs fix(uploads): store images in R2 so the Worker can boot #4 to have landed.pnpm run buildfails on pnpm 11 —pnpm-workspace.yamlstill ships the literal placeholderallowBuilds: { canvas: set this to true or false, … }, so esbuild/workerd never build. Worth a one-line fix in its own PR.Pre-existing
tscerrors incanvas-area.tsxanduploads.ts(missing@types/node/ workers types) are untouched.