Slothpress:25 MB 模型選錯引言卡字體,作者仍決定推出
I let a 25 MB model choose the typeface for my quote cards. It got it wrong, and I shipped it anyway.
Slothpress加入由 MobileBERT 在瀏覽器內為引言卡推薦字體樣式的功能,但模型選錯了,作者仍決定推出。功能以 25.72 MB 的量化 ONNX 模型為八款字體預設評分,並由用戶確認選擇。引言內容不會離開用戶裝置;模型無法載入時,程式會改用關鍵字計數並在畫面説明。
This is a submission for the Hacktoberfest Open-Source AI Challenge Week 3: Slow and Steady
What I Built
Slothpress turns a few words into a typeset quote card you can post anywhere. Type a quote, pick a style, download a 2160 × 2700 PNG. Everything happens on your device: no account, no upload, no server, no API key.
This week I gave it an open-weight model. Press Suggest a style and a MobileBERT checkpoint reads your quote and ranks the eight typography presets against it, then applies the one it likes best. The model runs in the browser tab. Your quote never leaves it.
The interesting part is not that a model can pick a font. It is everything that had to be true for a 25 MB model to be allowed to pick a font inside an app whose entire pitch is "your text never leaves your machine."
Demo
$ node verify-suggest.mjs http://localhost:4175/ "The sea was vast and deep that night."
--- ready in 13.6s ---
local: 81790 bytes
external: 25.93 MB
--- external requests ---
25.73 MB https://us.aws.cdn.hf.co/.../Xenova/mobilebert-uncased-mnli/onnx/model_quantized.onnx
0.19 MB https://huggingface.co/.../mobilebert-uncased-mnli/tokenizer.json
0.00 MB https://huggingface.co/.../mobilebert-uncased-mnli/config.json
0.00 MB https://huggingface.co/.../mobilebert-uncased-mnli/tokenizer_config.json
--- console errors --- (none)
--- page errors --- (none)
PASS
And the ranking it produced:
| rank | preset | score |
|---|---|---|
| 1 | Ink — stark, technical and to the point | 44% |
| 2 | Ember — warm, close and a little urgent | 27% |
| 3 | Tide — deep, vast and contemplative, like the sea | 22% |
That is the wrong answer. I will get to why, and to why I shipped it anyway, because the second one is the more interesting story.
Code
Slothpress
Turn a few words into a typeset quote card you can post anywhere. Everything happens on your device: no account, no upload, no server, no API keys. Open the page, type something, download the image.
Why
Most quote-card tools are a form, a server, and a login. The text you paste leaves your machine, the layout is decided by someone else's template, and the result is a link that stops working when the service does.
Slothpress is a single static page. The card is a deterministic function of the text and the style:
card = render(content, author, style)
No randomness, no model, no network. The same input always produces the same image, which means the layout can be checked by a machine instead of by taste.
That sentence is about the renderer, and it still holds: there is no model in the code that draws the card. What is…
How I Built It
The model is not allowed near the renderer
The whole app is one idea, and until this week it had no model in it at all:
card = render(content, author, style)
The same input always produced the same image. That is what let me check the layout by machine instead of by taste, and it is the reason the engine has 79 tests that run in 715 ms with no browser attached.
Adding a model could have quietly destroyed that. So the rule was: the model picks a style, and nothing else. It never writes a pixel, it never edits your words, and a human confirms the choice. The renderer still receives exactly one explicit style value, so the property survives. If the model is wrong, the card is still deterministic — you just pick a different preset.
Zero-shot with the model that was already a classifier
I did not want to ship a classifier model. But natural language inference already is one. For each preset I wrote one sentence describing its mood:
const STYLE_HYPOTHESES = {
midnight: 'quiet, still and a little lonely, like a room at 3am',
dusk: 'calm and thoughtful, the light going down',
ember: 'warm, close and a little urgent',
moss: 'grounded, natural and unhurried',
paper: 'plain, plain-spoken and unadorned',
ink: 'stark, technical and to the point',
tide: 'deep, vast and contemplative, like the sea',
candy: 'bright, playful and a little bit loud',
};
The quote is the premise. This quote is {hypothesis}. is the hypothesis. The entailment score for each hypothesis becomes the score for that preset, and the eight are normalised to sum to one. transformers.js has a zero-shot-classification pipeline that does that arithmetic for me.
Model: Xenova/mobilebert-uncased-mnli, quantized ONNX, 25.72 MB. I looked at deberta-v3-xsmall (83 MB) and said no: triple the download for a feature that is opt-in anyway, and the mistakes are recoverable because the scores are on screen.
Three things that had to be true
Nothing downloads until it is asked for. @huggingface/transformers sits behind a dynamic import(), so it is a separate chunk. The app shell stays at 78 KB gzipped and a visitor who never presses the button never fetches a byte of it.
The runtime comes from this origin. transformers.js defaults to pulling the ONNX Runtime Web build from jsDelivr. A third-party CDN in the runtime path of a tool whose pitch is "no server, no upload" is a bad look, and a jsDelivr outage would be a broken feature. Vite can hand me the runtime files as URLs from my own build output:
import ortMjsUrl from 'onnxruntime-web/ort-wasm-simd-threaded.asyncify.mjs?url';
import ortWasmUrl from 'onnxruntime-web/ort-wasm-simd-threaded.asyncify.wasm?url';
env.backends.onnx.wasm.wasmPaths = { wasm: ortWasmUrl, mjs: ortMjsUrl };
Both files land in dist/assets/ and are served from the app's own origin, where the service worker can cache them. The verification run confirms it: there is no jsDelivr request anywhere in the log.
The fallback admits what it is. If the model cannot load — no network, blocked WebAssembly, a browser without SIMD — the app falls back to counting keywords, and says so on screen in plain words. A silent fallback would be a lie about what happened.
What it costs, measured rather than added up
| App shell, first load | 78 KB gzipped |
| transformers.js chunk | 164 KB gzipped |
| ONNX Runtime Web | 6.7 MB gzipped |
| Model weights | 25.7 MB |
| Tokenizer | 190 KB |
| Ready in | 13.6 s |
I measured this with a logging static server in headless Edge rather than adding the numbers up, because the page-level network log misses the runtime: ONNX Runtime fetches it in a worker through the Cache API, so it never appears in Resource Timing. That is why the table above says 6.7 MB and not zero — the first version of this measurement said zero, and the server log disagreed.
So: the first press on the button costs about 33 MB and about 14 seconds. After that the browser caches both, and it works with the network switched off.
And the part I got wrong
Now the part I do not like.
Ask it about "The sea was vast and deep that night." and it ranks Ink first — stark, technical, to the point — and puts Tide, whose description is "deep, vast and contemplative, like the sea", third. The right answer is not just below the wrong one. It is below a completely unrelated one.
That is what a 25-million-parameter model with a strong prior over its own label set does. It is not really reading the quote. It is scoring how much each mood sentence looks like something an NLI model likes, and "stark, technical and to the point" happens to look like something it likes.
There are cheap fixes. multi_label: true changes the per-label normalisation from a softmax across all eight labels to an independent entailment-versus-contradiction softmax per label, which usually calibrates better for a small model. The hypotheses could be reworded to be less similar to each other. Either is half an hour of work, and I had the time.
I shipped it anyway, with the failure written into the README, for a reason I think is the right one for this challenge: a model you cannot check is not open innovation, it is a black box with a download button. So the scores are always on screen, the ranking is one click from being replaced, and the fallback tells you when it is the fallback. You can disagree with the model in one click, because you can see what it said and how sure it was.
That is the difference between running an open model and merely hosting one.
The rest of the app is still the part I am proudest of
Slothpress started as a blueprint with six things in it that did not work. Each is the kind of bug that looks fine on the screen you built it on:
-
The gradient was never read. The stored
bg_valuewas a CSS string, but the renderer hard-coded its own stops, so every card had the same background no matter what you picked. -
text_aligndid nothing. Every line was drawn atx = 1080 / 2, whatever the alignment said. - Long words ran off the card. A URL in a quote had nowhere to go.
-
The canvas was sized in device pixels.
canvas.width = 1080 * dpron a 3× phone is a 2160 × 2700 backing store for a preview a third of that on screen — 23 MB of canvas to draw something small. -
Long text ran into the attribution. The attribution sat at a fixed
y, so a long quote landed on top of it. -
Nothing auto-fitted.
font_size * 2was a magic number, not a rule.
Each one has a test that fails without the fix. Fitting is now a binary search over the font size — height is monotonically non-increasing as the font shrinks, so six wraps replace fifty-eight, and the worst case in the corpus (400 words) fits in 5.6 ms at p95. If the text still does not fit, the card says so instead of quietly truncating it. A card that quietly drops half your poem is worse than a card that admits it cannot hold the poem.
There is a server half too, even though the app does not need one. db/schema.sql is the Postgres schema that would back sharing between devices, with row level security written out rather than assumed: write policies carry WITH CHECK as well as USING, (select auth.uid()) is used instead of the per-row bare form, no policy references its own table, and every security definer function pins search_path to empty. db/rls-audit.sql checks the shape of those policies and db/rls-scenarios.sql checks what they actually do by trying to read somebody else's card as anon and as a signed-in stranger.
Why Does Open Innovation Matter?
For this project, open is not a preference. It is the reason the thing works at all.
It is the only way the cost curve flattens. A closed API bills per suggestion. A quote card is something you make dozens of times and would never pay a subscription for, so a metered model would have to be either free or absent — and it would not be free. With open weights and a local runtime, the first download is the last download. It is 33 MB once, then nothing, forever, with no key to expire and no bill to arrive.
It is the reason the feature can exist offline at all. The whole claim of this app is that your text never leaves your machine, and that claim is only true if the thing keeps working when the network is gone. A hosted model is a monthly subscription to a promise you cannot keep.
And it is the reason I could check it. The model card is readable, the inference is inspectable, the scores are on screen, and the failure is documented. With a closed API I would have had a black box that occasionally announces a sea is "stark and technical", and no way at all to find out why. Everything in the stack — ONNX Runtime, transformers.js, MobileBERT, the app itself — is open source I can read, fork, run on a CPU, and fix. Nothing in it changes under me while I am looking at it.
That last point is the one I keep coming back to. International Sloth Day is on the 20th, and sloths have it figured out: they move at their own pace and let the world come to them. Open weights are how software does that. You take the slow download once, and then everything afterwards is at your pace, on your machine, at your price, which is nothing.
The lazy payoff, since the brief asks for it
The tedious part of making a quote card is not the typing. It is the ten minutes of staring at eight typefaces, four gradients and two alignments trying to work out which one a particular sentence wants, and then not being sure you got it right.
Pressing the button replaces all of it with one click. It will not always be right — you saw the sea — but it gives you a defensible starting point in 13.6 seconds and a ranked list of the alternatives, which is a better place to start than a blank grid of eight identical-looking buttons.
And the app still works if you never press it, if you are offline, if you block WebAssembly, or if you simply do not want a model choosing anything for you. The button is opt-in, the card is deterministic, and the thing that makes this a quote card — the part that actually draws the picture — is the part with no model in it at all.
My Agent Session
I built this end to end with an AI coding agent, including the measurement above, the self-hosting change, and the moment I found out the model was wrong and decided to ship it anyway. The session below is the whole thing, decisions and all.
Prize Categories
I'm entering:
- Overall — a fully offline quote-card generator with an open-weight model choosing the typeface, measured byte-for-byte rather than estimated.
- Entire — the agent session above shows how it was built, the jsDelivr dependency that had to be removed, and the failure I chose to document instead of hide.
來源:DEV Community · dev.to

