All projects Cantina · Write-up
A menu written for the tillEin Speiseplan für die Kasse
Cantina shows this week’s and next week’s menus of the Kochwerk canteens at work. The data was never meant for people: a small server fetches it, cleans it up and serves it as one compact document.Cantina zeigt die Speisepläne der Kochwerk-Kantinen bei der Arbeit für diese und nächste Woche. Die Daten waren nie für Menschen gedacht: Ein kleiner Server holt sie, räumt sie auf und liefert sie als ein kompaktes Dokument.
cantina.ewolution.cloud Source
- Type
- Web appWeb-App
- Stack
- Svelte 5 · Node · Canvas
- Year
- 2026
- Code
- Public on GitHubÖffentlich auf GitHub
A service only its own page may call
Kochwerk’s menu lives in webspeiseplan, a jQuery app that asks /index.php?token=…&model=… for everything: locations, outlets, the menu, categories and allergens. That endpoint sends no CORS headers, and without the site’s own Referer it answers with an empty 200. A browser on any other origin gets nothing, so Cantina needs a server.
The server is small and has no dependencies. It makes the eleven calls the menu needs (four of the models are asked for in both languages), builds one document from the answers and keeps it. The token those calls carry is printed in webspeiseplan’s own public bundle. If an answer ever comes back empty, the server reads the current token out of that bundle again and retries, so a rotated token fixes itself.
Names written for the till
None of the data was meant to be read by people. Categories are named for the cash register, English names are sometimes till shorthand, and plans archived years ago are still marked active. The normalizer turns it into something a menu can show:
| Green Daily Salad 1–4 | One station, “Green Daily Salad”, with its four salads in Kochwerk’s own order. |
|---|---|
| F&T Vegan Elbe | “F&T Vegan”: an outlet’s name at the end of a station is dropped. |
| Gulasch Pute Bi | Till shorthand in the English name, so the German name stands in for it. |
| A backup plan from 2021 | Still marked active, but its dishes are dated outside the weeks Cantina reads, so it never shows. |
| Allergen C | Fish. There’s no feature for it, so a fish dish is recognised by its allergen. |
Notes stay in their own language, so an English portion note never turns up under a German name. Plans that only exist for Kochwerk’s screens fold into the outlet’s main assortment, and no dish is listed twice. The rules are one pure function, tested on a miniature shaped like the real responses.
2.3 MB in, 15 KB out
The raw menu model alone is about 2.3 MB. Cantina’s document holds both weeks, every outlet and both languages, and compresses to about 15 KB. It counts as fresh for 15 minutes. After that the old copy is still served at once while a new one is fetched behind it, and the last good copy is written to disk, so a restart or an outage at Kochwerk never shows an empty page. Only the first request after midnight waits for a fresh fetch, because the week the document covers has moved.
Photos, plates and dots
The kitchen photographs most dishes on the day they’re served. The list doesn’t load those photos at their full 1,600 px. Kochwerk already renders a 205 px square of each one for its own apps, so Cantina asks for that, and falls back to the original when a square doesn’t exist yet. Photos go through Cantina’s server, which only fetches image paths from Kochwerk’s image host.
A dish without a photo gets a plate of dots instead: about 120 points on a sunflower spiral, sized by a few piles of “food” and coloured by what the dish is, vegan, fish or poultry. The plate is seeded by the dish’s name, so the same dish always gets the same plate, and the name shapes it a little: soup fills the bowl, a salad scatters, a pizza carries toppings. The outlet’s own photo is drawn the same way on a canvas, as halftone dots in its muted colours at the 4.6 px pitch of the portrait on this site. It is decoded off the main thread first, since one of the photos is 3,072 × 4,096.
A scroll problem that was a hover problem
The first version felt sluggish when scrolled with a mouse, and a scroll benchmark in headless Chrome showed why. With the cursor resting over the list, every row that slid under it switched into its hover state and back. The hover faded the row’s background, so each frame repainted the row, including shrinking a full-size photo into its thumbnail, and the generated plates added a hundred individually animated dots. Wheel scrolling ran at 30 frames a second.
- Hover only on layersThe highlight is its own layer that only fades, and photos and plates scale as whole elements. Nothing inside a row repaints.
- Hover paused while scrollingPointer events on the content pause until scrolling settles. Only for a mouse or trackpad, and any real mouse movement ends the pause, so it never swallows a click.
- Thumbnails in listsThe 205 px squares, so even a repaint is cheap.
Afterwards the same benchmark showed 60 frames a second, with input handled in about one frame, the same as a page of plain text. The other usual fix, a transparent overlay that takes the pointer while the page scrolls, measured slower in Chrome.
Every outlet on one page
The first tab, Overview, lists every outlet’s menu for the day as nothing but dish and price, with a link to each outlet’s full menu. Filters apply there too, so choosing vegan on that page shows which outlets have something vegan today. A simplified view in the settings brings the same plainness to the outlet pages: a name and a price per dish, and everything else a tap away.
What only a phone shows
The detail sheets are native <dialog> elements. On an iPhone they opened as a sliver with nothing but the grip. Their scrolling body was flex: 1 inside a dialog with a maximum height but no height, and Safari takes that zero basis literally, where Chrome sizes it by its content. Search had a problem of its own: the keyboard pushes a bottom sheet’s text field out of view, so on phones search hangs from the top instead and is sized to the area above the keyboard.
Ein Dienst, den nur die eigene Seite aufrufen darf
Der Speiseplan von Kochwerk steckt im Webspeiseplan, einer jQuery-App, die alles über /index.php?token=…&model=… abfragt: Standorte, Ausgabestellen, den Plan, Kategorien und Allergene. Dieser Endpunkt sendet keine CORS-Header und antwortet ohne den Referer der eigenen Seite mit einem leeren 200. Ein Browser auf einer anderen Herkunft bekommt also nichts, und Cantina braucht einen Server.
Der Server ist klein und kommt ohne Abhängigkeiten aus. Er macht die elf Abfragen, die der Plan braucht (vier der Modelle in beiden Sprachen), baut aus den Antworten ein Dokument und behält es. Das Token für diese Abfragen steht im öffentlichen Bundle des Webspeiseplans. Kommt eine Antwort einmal leer zurück, liest der Server das aktuelle Token erneut aus diesem Bundle und versucht es noch einmal; ein gewechseltes Token repariert sich so von selbst.
Namen für die Kasse
Nichts in diesen Daten war dafür gedacht, von Menschen gelesen zu werden. Kategorien heißen so, wie die Kasse sie braucht, englische Namen sind manchmal Kassenkürzel, und Pläne, die vor Jahren archiviert wurden, sind noch als aktiv markiert. Der Normalizer macht daraus etwas, das ein Speiseplan zeigen kann:
| Green Daily Salad 1–4 | Eine Station, „Green Daily Salad“, mit ihren vier Salaten in Kochwerks eigener Reihenfolge. |
|---|---|
| F&T Vegan Elbe | „F&T Vegan“: Der Name einer Ausgabestelle am Ende einer Station fällt weg. |
| Gulasch Pute Bi | Ein Kassenkürzel im englischen Namen, also steht dort der deutsche Name. |
| Ein Backup-Plan von 2021 | Noch als aktiv markiert, aber seine Gerichte liegen außerhalb der Wochen, die Cantina liest, also taucht er nie auf. |
| Allergen C | Fisch. Dafür gibt es kein Merkmal, also erkennt Cantina ein Fischgericht an seinem Allergen. |
Hinweise bleiben in ihrer eigenen Sprache, ein englischer Portionshinweis taucht also nie unter einem deutschen Namen auf. Pläne, die es nur für Kochwerks Bildschirme gibt, gehen im Hauptsortiment der Ausgabestelle auf, und kein Gericht steht doppelt da. Die Regeln sind eine reine Funktion, getestet an einer Miniatur im Format der echten Antworten.
2,3 MB rein, 15 KB raus
Allein das rohe Speiseplan-Modell hat rund 2,3 MB. Cantinas Dokument enthält beide Wochen, alle Ausgabestellen und beide Sprachen und ist komprimiert rund 15 KB groß. Es gilt 15 Minuten lang als frisch. Danach wird die alte Fassung trotzdem sofort ausgeliefert, während dahinter eine neue geholt wird, und die letzte gute Fassung liegt auf der Platte, sodass ein Neustart oder ein Ausfall bei Kochwerk nie eine leere Seite zeigt. Nur die erste Anfrage nach Mitternacht wartet auf frische Daten, weil sich die Woche verschoben hat, die das Dokument abdeckt.
Fotos, Teller und Punkte
Die Küche fotografiert die meisten Gerichte an dem Tag, an dem es sie gibt. Die Liste lädt diese Fotos nicht in voller Größe von 1.600 px. Kochwerk rechnet für seine eigenen Apps ohnehin von jedem Foto ein 205 px großes Quadrat; Cantina fragt genau das an und greift auf das Original zurück, wenn es noch kein Quadrat gibt. Fotos laufen über Cantinas Server, der nur Bildpfade vom Bildserver von Kochwerk abruft.
Ein Gericht ohne Foto bekommt stattdessen einen Teller aus Punkten: rund 120 Punkte auf einer Sonnenblumenspirale, bemessen nach ein paar Haufen „Essen“ und eingefärbt nach dem, was das Gericht ist, vegan, Fisch oder Geflügel. Der Teller hängt am Namen des Gerichts, dasselbe Gericht bekommt also immer denselben Teller, und der Name formt ihn ein wenig: Suppe füllt die Schale, ein Salat verteilt sich, eine Pizza trägt Belag. Das Foto der Ausgabestelle selbst entsteht genauso auf einem Canvas, als Rasterpunkte in seinen gedämpften Farben, im 4,6-px-Raster des Porträts auf dieser Seite. Dekodiert wird es vorher abseits des Main Threads, denn eines der Fotos ist 3.072 × 4.096 Pixel groß.
Ein Scroll-Problem, das ein Hover-Problem war
Die erste Fassung fühlte sich beim Scrollen mit der Maus träge an, und ein Scroll-Benchmark in Headless-Chrome zeigte den Grund. Ruht der Mauszeiger über der Liste, wechselt jede Zeile, die unter ihm hindurchgleitet, in ihren Hover-Zustand und wieder zurück. Der Hover blendete den Hintergrund der Zeile ein, also wurde die Zeile in jedem Frame neu gezeichnet, samt dem Verkleinern eines Fotos in voller Größe auf sein Vorschaubild, und die generierten Teller brachten noch hundert einzeln animierte Punkte mit. Scrollen mit dem Mausrad lief mit 30 Bildern pro Sekunde.
- Hover nur auf EbenenDie Hervorhebung ist eine eigene Ebene, die nur ein- und ausblendet, und Fotos und Teller skalieren als Ganzes. Innerhalb einer Zeile wird nichts neu gezeichnet.
- Hover pausiert beim ScrollenZeigerereignisse auf dem Inhalt pausieren, bis das Scrollen zur Ruhe kommt. Nur für Maus und Trackpad, und jede echte Mausbewegung beendet die Pause, damit sie nie einen Klick verschluckt.
- Vorschaubilder in ListenDie 205-px-Quadrate, sodass selbst ein Neuzeichnen billig ist.
Danach zeigte derselbe Benchmark 60 Bilder pro Sekunde, und Eingaben wurden in etwa einem Frame verarbeitet, so schnell wie bei einer Seite aus reinem Text. Die andere übliche Lösung, eine durchsichtige Ebene, die während des Scrollens den Zeiger abfängt, war in Chrome messbar langsamer.
Alle Ausgabestellen auf einer Seite
Der erste Tab, die Übersicht, zeigt den Tagesplan aller Ausgabestellen nur als Gericht und Preis, mit einem Link auf den vollständigen Plan jeder Ausgabestelle. Die Filter gelten auch dort, die Auswahl „Vegan“ zeigt auf dieser Seite also, wo es heute etwas Veganes gibt. Eine vereinfachte Ansicht in den Einstellungen bringt dieselbe Schlichtheit auf die Seiten der Ausgabestellen: Name und Preis pro Gericht, alles andere einen Tipp entfernt.
Was nur ein Telefon zeigt
Die Detailansichten sind native <dialog>-Elemente. Auf einem iPhone öffneten sie sich als schmaler Streifen mit nichts als dem Griff. Ihr scrollender Inhalt war flex: 1 in einem Dialog mit einer Maximalhöhe, aber ohne Höhe, und Safari nimmt diese Null-Basis wörtlich, wo Chrome sie am Inhalt bemisst. Die Suche hatte ein eigenes Problem: Die Tastatur schiebt das Textfeld eines unteren Panels aus dem Bild, also hängt die Suche auf Telefonen stattdessen oben und ist so hoch wie der Bereich über der Tastatur.