A restaurant brand drawn in Python
Logo, stickers, bags, a locking taco box and a website — every file generated by a script, and every claim about it checked by one
Burrito District is a small restaurant in Amman that I am involved with. On 18 September it had a logo — one AI-generated JPEG sent over WhatsApp — and nothing else. No vector file, no packaging, no website, no colour rules.
A week later it has a vector logo, a locking taco box on a real dieline, a paper bag, two plastic bags, two bag seals, six sandwich stickers, a print package with a spec sheet per item, a QR code, an installable font, and a bilingual website on staging. Nothing in that list was drawn by hand. Every file comes out of about 5,500 lines of Python in one folder, and changing a design means editing a script and re-running it.
That was not a principle at the start. It became one after the first round of feedback, when "same thing, but terracotta" turned out to be a one-line change instead of an afternoon.
What this covers
- Tracing a blurry JPEG into a clean four-colour vector
- A tiny scene model that writes real print PDFs — CMYK, spot inks, layers, bleed
- The option → pick → print-file loop that a client can actually drive
- A two-colour rule, and the pixel audit that proves it holds
- Straightening hand-drawn lettering, and turning it into a font
- Re-engineering a supplier's box so the lid locks, checked by folding it in 3D
- The website: static Next.js, a scroll-scrubbed hero, and a contrast ratio that set the whole type scale
- The QR code that ties the packaging to one URL forever
- What is still open — which is most of the content
Why code, not Illustrator
I don't own Illustrator and I am not a designer. What I can do is describe a shape precisely. A brand identity turns out to be mostly precise description: this hat, at this width, this far from the fold, in these two inks, on a 60 mm circle with 3 mm bleed.
| Hand-drawn | Generated |
|---|---|
| Recolouring 30 mockups takes a day | one constant, one re-run |
| "Move the logo down 5 cm" touches every file | one number in one builder |
| Colours drift between files | one colour table, one source |
| Nobody can prove the print file has no stray RGB | a check can, and does |
| The option you rejected is gone | it is still one function call |
The last row mattered more than I expected. The restaurant picked the layout of the final taco box from a round of options three rounds earlier. Because the old round was a function, not a pile of PNGs, bringing it back in the new colours took minutes.
Step one: a vector logo from a WhatsApp JPEG
The logo was a sombrero over arched lettering, four flat colours, delivered as a compressed JPEG. Every edge had JPEG noise on it and every boundary between two browns had an anti-aliased rim of a third colour.
logo-trace/trace.py turns it into vectors in five steps:
- Measure the palette from "core" pixels only — each rough colour region
eroded by 3 px so edges don't pull the median. Result: black
#000000, dark brown#250d01, brown#3e1d0b, cream#efd9b4. - Upscale 4× and classify. A bilateral filter first, to knock down the JPEG mosquito noise, then each pixel is assigned a class by its red channel. The red channel alone separates all four colours, which made thresholds simple.
- Kill the false rims. A thin band of class B sitting between a darker and a lighter class is anti-aliasing, not artwork. Those pixels are reassigned to whichever neighbour they are closer to.
- Merge specks under a size threshold into their dominant neighbour.
- Trace stacked layers with potrace:
class ≥ 1,class ≥ 2,class ≥ 3, each traced as one silhouette and painted on top of the previous one.
layers = [(PAL[k], trace(L >= k, 'layer%d' % k)) for k in (1, 2, 3)]
Stacking is the trick. Tracing each colour as its own region leaves hairline gaps where two traced edges don't quite meet, and on a dark background those gaps show as thin light lines. Traced as stacked silhouettes, every colour sits on top of the one below and there is nothing to show through.
verify.py then renders the vector and diffs it against the source:
0.15% of pixels differ by more than 40 levels, all on edges that were blurry
in the JPEG to begin with.
A traced logo is still one lump. To put the hat alone on a seal, or the words alone on a box,
extract.pyseparates the hat from the lettering and the decorative splashes — including rebuilding the part of the hat brim that was hidden behind the letters and never existed in the JPEG.
A scene model that writes print files
bd.py, 329 lines, is the core. It is a very small graphics library:
Doc→Layer→ items. Each item is bezier paths plus a fill and stroke name. Units are points, y-down, with anMMconstant so sizes read as60*MM.- One colour table. Each named colour has an RGB for screen, a CMYK for print and optionally a spot-ink name. Nothing else in the codebase is allowed to write a colour.
- Writers for SVG, PNG (through PyMuPDF), and a hand-written PDF writer.
The PDF writer is the part worth describing, because it is the part a printer actually looks at:
| PDF feature | Why the printer cares |
|---|---|
DeviceCMYK fills |
the numbers they print are the numbers in the file |
Separation colour spaces |
a spot ink ("BD Terracotta", "White Ink") becomes its own plate |
| Optional-content layers | CUT, FOLD, GUIDE can be switched off and never print |
TrimBox / BleedBox |
where the knife goes and how far the colour must run past it |
The .ai files are the same PDF with a different extension — modern Illustrator
opens a PDF-based .ai natively. I have no Illustrator to prove it with, so a
.pdf sits beside every .ai as the fallback.
Every print file is then checked by a script, not by eye: pikepdf opens it in strict mode, PyMuPDF counts what is inside. The standard is 0 embedded images and 0 fonts — every letter is outlines, so the printer needs nothing installed.
Never name a script
packaging.py. It shadows the PyPI module of the same name, which pikepdf imports, and the error you get says nothing about it. The file ispack.pynow.
Options, picks, print files
The restaurant does not write specifications. They look at pictures and point. So the loop that worked was:
- I generate a round of PNG mockups — cheap, 3D-ish, on textured paper.
- They pick one, or say "this layout, those colours".
- Only the winner becomes print files.
The mockup engine (mockups.py) is a tiny pinhole camera: a panel rendered
flat is perspective-warped onto a quad, multiplied by a paper-fibre texture,
shaded and dropped onto a backdrop with a shadow. It is crude, but it answers
"what does this look like as a bag" in about 20 seconds per board.
The rounds, in order:
| Round | Options | Picked |
|---|---|---|
| Sandwich stickers — styles B–F, then 7 palettes of the flat style | 5 + 42 | flat style, palette 3 |
| Bag, round 1 (with handles) | 12 | three favourites |
| Bag, round 2 (the real bag has no handles) | 20 | — |
| Taco box directions | 30 | — |
| Two-colour concepts — bag + box + sticker + seal each | 15 | the logo sticker from concept 1 |
| Two-colour logo alternatives | 15 | superseded by the above |
| Variants of the chosen logo | 16 | the arched clean badge |
| Box designs on the final logo | 16 | none — they went back to box option 11's layout |
| That layout in the final colours | 12 + 3 | two finalists |
| Paper bag in the final style | 6 | solid hat, full front |
| Clear plastic bag | 4 | both types |
Two lessons from running this loop:
- They pick by layout, not by round. Nothing is superseded until the job ships. Old option folders stayed untouched on purpose.
- "Without changing the design" means call the original builder. When they asked for the old seal "in our colours, without change", the right answer was the same function with the same numbers and one colour swapped — not a redraw that looks close.
The two-colour rule, and proving it
On 21 September the restaurant made the decision that shaped everything after it: terracotta and cream, and nothing else. No black, no white, no brown, no gradients.
That rule is easy to state and easy to break by accident. A shadow in 60% black. A white page showing through at the edge of a render. A brown from the old palette in a helper nobody touched. So it is enforced in two places.
In the scene, before anything is written:
def check(*docs): # the brief: two colours, nothing else
for d in docs:
for l in d.layers:
for it in l.items:
assert it.fill in (T, C) and it.stroke in (None, T, C)
In the pixels, after rendering. Anti-aliasing is fine — an edge pixel is a mix of the two colours — so the audit does not ask "is this pixel terracotta or cream". It asks whether the pixel lies on the line between them in RGB space, within a small tolerance. Anything off that line — grey, white, a stray brown — is a failure.
The first audit failed on every single mockup, on a one-pixel frame around the
edge. The rasteriser blends the last row and column with the white page
underneath. edge_fix() copies the neighbouring row over it. Invisible to the
eye, and exactly the kind of thing the check exists to catch.
One-colour art on textured paper needs a real knockout. Filling "the cream parts" with cream on a kraft-textured mockup shows up as a flat, fake-looking patch. The shapes have to be subtracted from the ink so the paper itself shows through.
There was also a colour to settle. The logo JPEG's terracotta measured
#a93b22; the seal they picked measured #b5452a. I flagged it rather than
choosing, and their logo pick settled it: #b5452a, everywhere.
Straightening hand-drawn letters
The logo's lettering is hand-drawn — no typeface matches it. Anton with spurs added in code got close enough for the early sandwich stickers, but on the final layouts the difference was visible next to the real logo.
The only real source for those letters was the logo, where they are arched.
_straighten() in variants2c.py un-arches them:
- Sample the tops and bottoms of every letter.
- Fit a curve to each — a robust quartic, so one tall letter doesn't bend it.
- Map every point so the top curve becomes a straight cap line and the bottom curve a straight baseline.
- Level each letter again individually, because a global fit leaves each one slightly tilted.
o[:, 1] = -cap * (yb - c[:, 1]) / (yb - yt)
That one line is the whole idea: a point's height becomes its fraction of the
way between the fitted bottom (yb) and top (yt) at that x, times the cap
height.
The result is the logo's own letters, upright, usable on one line, stacked, or
set back onto a circle. DISTRICT was drawn about 10% narrower than BURRITO,
which is invisible when stacked and obvious on one line, so one-line layouts
widen it.
Then it became a font
The restaurant asked for the fonts, for their own use. The honest answer is that
the name is not a font. So I made one: fontbd.py builds
BurritoDistrictLettering-Regular.ttf with fontTools.
| Glyphs | Source |
|---|---|
| B U R I T O D S C | traced from the logo and straightened — exact |
| every other capital, 0–9, punctuation | Anton, widened ×1.36 to the traced proportions, corners faceted, spurred, slightly emboldened |
| lower case | the same capitals — the style is caps only |
The 1.36 is not a guess: it is the median width ratio between Anton and the traced letters over the nine letters both have. The L gets no spur — a spur on the L's stem reads as a Polish Ł.
A box that locks
The restaurant's packaging supplier sent a dieline for a flip-lid box, 300 × 160 × 70 mm inside, and asked for a side lock. Reading it closely, the original could not work as drawn: a 25 mm tuck on the end of a 70 mm lid front has nowhere to tuck into.
lockbox.py (839 lines, the largest script) rebuilds it:
- The lid front closes outside the front wall, with a lock tab on each end that pushes into a 31 × 1.2 mm slot in the side wall. The tab's neck is 30 mm, so there is 0.5 mm of play at each end; lifting the lid pulls the neck onto the slot end — locked.
- The lid's side flaps close inside the side walls, and their front edge is cut on an arc centred on the hinge, so they cannot catch the front wall while the lid swings shut.
- Every panel is compensated for a 0.5 mm board, so the inside measures exactly 300 × 160 × 70.
And then it checks itself, by folding the exact die geometry in 3D:
| Check | Result |
|---|---|
| One-piece sheet | 1 part, 441 × 582.5 mm |
| Panels only meet at their creases | ok |
| Each slot at least 15 mm from any edge or crease | 18.9 mm |
| Box shut: no two boards overlap | 0 overlaps |
| Lid swung from 100° to shut, every 0.5°: lid never hits the tray | pass |
The output is a 1:1 dieline plus a four-page A3 tech sheet with the folding sequence drawn from the same model. The final artwork is mapped onto this real dieline — the lid rotated 180°, because the hinge is at the back — and folded in 3D once more as an orientation proof.
Don't mirror a texture to get the other side. The first back-view mockups made the left wall by mirroring the right one. That mirrors the hat and the text too. The left wall needs to be its own face.
The print package
printready.py writes one folder per item plus a 00-READ-ME-FIRST.pdf. Each
folder has the print files in two versions — two spot inks and CMYK —
previews, mockups and a one-page PRINT-SPEC.pdf that lists what the printer
must confirm before printing.
The spec sheets carry the decisions a printer would otherwise make silently:
- The paper bag must not be brown kraft. The design is terracotta with cream artwork; on brown paper terracotta goes dull and cream cannot be printed at all. White or cream stock.
- Plastic bags get two plates. Ink on clear film is see-through, so a white backing prints first, under the terracotta, choked 0.25 mm so it never peeks out at the edges.
- Nothing cream on clear film. Detail is left unprinted instead.
What the printer still has to confirm — and I say so in the files rather than guess quietly:
| Open | Why it matters |
|---|---|
| Box board caliper | the dieline is compensated for 0.5 mm; a different board needs a re-run |
| A white sample box, tested with real tacos and the lock | before anyone pays for a cutting die |
| Plastic bag sizes | mine are assumptions; the art refits in minutes once the real size is known |
| Pantone references | approximate; CMYK values are exact |
| Which of the two finalist boxes | never chosen, so both shipped |
The website
The site is on staging at burrito.oyware.com, marked noindex. It is built
from the same assets as the packaging.
| Piece | Choice |
|---|---|
| Framework | Next.js 16, App Router, static export — plain files served by Nginx on my droplet |
| Styling | Tailwind 4 + shadcn/ui, re-pointed at two brand tokens |
| Motion | motion for small reveals; none in the hero |
| Content | typed files in the repo, no CMS |
Two colours, again
The site obeys the same rule as the packaging. brand/tokens.css holds exactly
two colours. "Dark mode" became a terracotta page ↔ cream page switch. The
flavour colours exist only inside the six sticker images.
The same pixel audit runs here, in Playwright: every page, both page colours, desktop and phone, every pixel checked against the terracotta–cream line (and each sticker's own flavour lines). It passes with under 0.05% stray pixels, all lossy-WebP edge noise.
The contrast ratio that set the type scale
Terracotta on cream measures 3.96:1. That is below the 4.5:1 WCAG asks for normal text, and above the 3:1 it asks for large text.
With two colours there is no darker ink to reach for. So all text is large:
- Anton never below 24 px — headings and buttons
- Barlow Condensed 700 never below 19 px — body and labels
- no text ever dimmed with opacity (70% would measure 2.7:1)
That constraint made the type scale, not the other way round. Lighthouse accessibility is 100.
The scroll hero
The brief was the Apple-style product scroll: a burrito that unwraps as you
scroll and the fillings rise out. Photoreal frames needed an image-model API key
that wasn't there, so the frames are illustrated, drawn by the same Python
toolkit (heroanim.py), and drawn on a canvas by scroll position.
Each frame is a single alpha mask — paper opaque, ink lines as holes. The canvas fills it with the page's current ink colour, so one frame set works on both page colours. 96 frames for desktop, 48 for phones, fetched after load.
Two things learned the hard way:
- Chrome holds first paint until every image in the first viewport arrives
— including a CSS mask inside a
display: noneblock and a lazy image that is merelyvisibility: hidden. The first build scored 85, with first paint at 3.3 s. Inlining the two start posters as data URIs and not referencing any other image until needed brought it to 97, first paint 1.7 s. - Frames go in versioned folders.
/hero/is cached for 30 days, in browsers and at Cloudflare's edge. Replacing frames under the same names left the old ones on screen after deploy. New frames now go to a new folder (burrito-v1, thenburrito-v2) and the old URL is simply never asked for again.
Purging Cloudflare. The dashboard's API purge is blocked by Cloudflare's own WAF when called from the dashboard tab. The UI works, and on the free plan Custom Purge has a prefix option — one folder, not the whole site.
Arabic
Arabic lives under /ar/ with English at the root. There is no language
redirect, because a static export cannot read the URL and, more importantly,
the printed QR codes must keep opening exactly what they always opened. Two
root layouts share one document component, because <html lang dir> must be
on the root element.
One detail worth copying: the hero drawing doesn't mirror in right-to-left, so the filling labels keep left-to-right geometry and only the words inside them run right to left.
Missing content is a type
Most of the restaurant's content does not exist yet: prices, hours, branches, the real fillings, contact details. Rather than lorem ipsum, a missing value is a typed object:
export const tagline = todo("Q2", "One-line intro in the owners' words");
It renders as a dashed TODO box — the one place the design system allows a
box, because a placeholder that blends in is a placeholder someone ships. The
branches, opening hours and "open now" badge are built and hidden until the
data is real.
One QR code, forever
The taco box and the bag carry a QR code, and that makes one URL permanent.
It points at /qr/ — a links page (menu, Instagram, WhatsApp, call, website)
with zero JavaScript, 2.3 KB gzipped. The URL is encoded with the trailing
slash so a phone on mobile data does not spend a redirect hop. Error correction
is level Q (25%), so a crumpled bag or a grease mark still scans. Terracotta on
cream, never the reverse: a scanner needs the code darker than its background.
qrcode2c.py doesn't just write the files. It decodes its own output with
OpenCV at phone-camera size and fails if the result isn't the URL.
The consequence is the part to remember: the printed code points at the
staging hostname. Whatever domain the restaurant picks later, that hostname
must keep serving /qr/ — or 301 to the new one — for as long as a single box is
in circulation. Links on the page can change freely; the URL cannot.
Letting agents build it
The website's first five phases were built by agents from Linear tickets:
Cyrus on my Mac picks up a ticket, runs Claude Code in a worktree, pushes, and
deploys to staging. A small launchd job started the next ticket whenever the
previous one landed a Fixes … commit, with the model chosen by a tag in the
ticket text.
It worked, with two corrections:
- Three phases in parallel hit the usage limit about ten minutes in and all died mid-phase, then re-read every doc from scratch on restart. The chain was changed to one ticket at a time, waiting out limit resets instead of counting them as failures.
- After phase 5, the remaining work moved into one interactive session. Tickets suit small, separate tasks; a redesign that touches every page does not.
The trap the agents didn't catch and the deploy script now does: svgo wrote the
brand SVGs as mode 600, rsync carried that to the server, and two images served
403 to every visitor for days. Every page returned 200, so the health check
was happy. deploy.sh now makes the output world-readable before upload and
requests every file the pages reference, not just the pages.
Online ordering, in preview
The newest part is customer-side online ordering: a basket, checkout, delivery or pickup, cash only, and an order-status page, in both languages. It is hidden from the public and has no backend yet. How the restaurant receives orders, who presses which button, printers, fees and delivery areas decide the shape of that backend, and those are questions for the owners, not for me to guess. The answers come first.
What is still open
Honestly, most of what a customer would read:
- The real menu, prices and descriptions
- The real fillings for the hero labels
- Contact details, branches and opening hours
- Food photography, and whether it should be duotone or full colour
- The domain — and with it, the redirect the QR code will need
- Printer confirmation on board, bag sizes and inks
Each of these is a ticket with the question written out, waiting for an answer from the restaurant. The code side is ready for them: new content is a value in a typed file, a new sandwich sticker is one line in a list, and a new colour is one entry in one table and a re-run.
That last sentence is the whole argument for building a brand this way.