robzepdev

robzepdev

@robzepdev.bsky.social

Frontend Dev (3 YOE) x AI Builder. https://robertoz.com | https://calibrodigitale.com

25 followers56 following63 posts

Verdict now reads in five languages. The thing it shows cannot be translated, so the page says so out loud. Release notes are kept verbatim, summaries are generated in English, and an Italian page carries a one-line notice about it. 🧵

View full thread

Translating the layout is the other half. Four verdicts say less in Italian, because the line holds 26 characters including the label. German needed two verbs for follow: folgen takes the dative, but it forms no attributive participle.

Verdict now reads in five languages. The thing it shows cannot be translated, so the page says so out loud. Release notes are kept verbatim, summaries are generated in English, and an Italian page carries a one-line notice about it. 🧵

View full thread

One rule that saved the rest: next/link is imported in exactly one file, inside a LocaleLink. A link that forgets the prefix drops the reader back into English halfway through a path, and a typecheck never sees it.

Some words refuse to move. featured, notable, digest, preview, note:, ok: stay English in every language, because they are the same tokens that appear inside the summaries. The landing's legend is translated: that is where those words get explained.

Verdict now reads in five languages. The thing it shows cannot be translated, so the page says so out loud. Release notes are kept verbatim, summaries are generated in English, and an Italian page carries a one-line notice about it. 🧵

View full thread

Why the summaries stay English: each extra language is one more model call per release. Cheap per call, times five languages, times every release of 37 technologies. So the notice shows up only where a second language is actually on screen.

The locale went in the URL, not in a cookie. A cookie read at the root makes every page render per request and kills ISR. A prefix instead, English unprefixed, and the build still prerenders 70 static pages. The same pages as before, times five.

Verdict now reads in five languages. The thing it shows cannot be translated, so the page says so out loud. Release notes are kept verbatim, summaries are generated in English, and an Italian page carries a one-line notice about it. 🧵

Why the notes stay verbatim: every claim in a summary points at the line it was read from. Translate the notes and the pointer points at nothing, so the one check a reader can run on an LLM's output stops working.

I spent this week redoing the look of every screen in Verdict. The design work is the boring part of this thread. The interesting part is four things the browser was doing that I could not see, and one of them had been live and broken in production the whole time. 🧵

View full thread

My design doc had been caught out of date four sessions running, so the typography table is now held by a test in both directions: nothing in the doc the CSS does not render, nothing in the CSS the doc does not state. The prose is still held by nothing.

I spent this week redoing the look of every screen in Verdict. The design work is the boring part of this thread. The interesting part is four things the browser was doing that I could not see, and one of them had been live and broken in production the whole time. 🧵

View full thread

Then the ones the eye cannot catch. A clamp in vw inside a fixed column measures the wrong thing: above ~1340px 'ok: nothing needs action' asked for 346px inside a 326px panel. The copy never grew. The clamp grew with the viewport while the column stayed 360px.

Three differences from the mocks came out of measuring, not looking: a row set at 14px where the mock says 13, a separator sitting under a row instead of over it, and a hand-drawn chevron missing its curve. None of the three is visible by eye.

I spent this week redoing the look of every screen in Verdict. The design work is the boring part of this thread. The interesting part is four things the browser was doing that I could not see, and one of them had been live and broken in production the whole time. 🧵

View full thread

So under reduce the shimmer becomes a fade between 0.6 and 1 opacity. No direction, no translation, no parallax. That is the substitution WCAG 2.2 asks for in 2.3.3, rather than nothing at all.

Fourth, on the landing: an IntersectionObserver never sees a jump. Press End, or follow an anchor, and a band travels from below the viewport to above it without crossing a threshold. No callback fires. It stays invisible for the rest of the visit.

I spent this week redoing the look of every screen in Verdict. The design work is the boring part of this thread. The interesting part is four things the browser was doing that I could not see, and one of them had been live and broken in production the whole time. 🧵

View full thread

Second one: I built loading skeletons and then could not see my own loading state. Fill #161a20 on a #0f1115 page is three points apart. What arrived was the 1px border alone, and an empty border reads as a broken control, not as waiting.

Third: my own Chrome asks for prefers-reduced-motion: reduce, and my CSS froze the shimmer completely. A loader is not decoration. Stopped, it is indistinguishable from a product that has crashed. That setting has a right to the kind of motion, not to its absence.

I spent this week redoing the look of every screen in Verdict. The design work is the boring part of this thread. The interesting part is four things the browser was doing that I could not see, and one of them had been live and broken in production the whole time. 🧵

View full thread

The cause is one line of my own CSS. globals.css turns off Tailwind 4's default spacing scale with --spacing: initial. In Tailwind 4 the inset utilities are built from that scale. No --spacing-0, no top-0 class. It is not an override losing. The rule is never written.

The same hole takes out right-0, left-0, bottom-0, inset-0 and gap-0. The other live one was a phone:gap-0. m-0, p-0, min-w-0 and min-h-0 do survive, which is part of why nothing looked obviously wrong. I checked them one at a time.

I spent this week redoing the look of every screen in Verdict. The design work is the boring part of this thread. The interesting part is four things the browser was doing that I could not see, and one of them had been live and broken in production the whole time. 🧵

Start with the one that was live. The top bar had never been sticky. Not on my machine, not in production. It had position: sticky with top: auto, which sticks to nothing, and it had shipped that way.

The AI summaries are live on the tracker now. Not a local build, not a mock: this is production, grounded on the official release notes only, with a line pointer next to every claim. 126 of them went out in a single batch. Here is what that actually cost me.

View full thread

Unrelated bug, same lesson. My monospace column budget assumed 0.6em per character, the number everyone knows. Fragment Mono advances 9px at 14px. 6 overflows out of 13,496 checks, all at 768px, on lines of exactly 79 characters. Remeasured, then 0 of 13,496.

The AI summaries are live on the tracker now. Not a local build, not a mock: this is production, grounded on the official release notes only, with a line pointer next to every claim. 126 of them went out in a single batch. Here is what that actually cost me.

View full thread

The fix is a gap, not a bigger number. The schema now allows 140. The prompt still asks for 120. The model keeps aiming at 120, and the few characters it overshoots land in the margin instead of throwing away a paid summary. Asking for 140 would just move the wall to 141.

Then the trap that nearly made me pay twice: a batch's expires_at is 24 hours from creation, not the 29 days the docs quote for result retention. I had saved all 126 results to disk that same day. That is the only reason re-importing the 4 was free.

The AI summaries are live on the tracker now. Not a local build, not a mock: this is production, grounded on the official release notes only, with a line pointer next to every claim. 126 of them went out in a single batch. Here is what that actually cost me.

View full thread

4 of the 126 came back failed. Not hallucinations, not malformed JSON. Titles of 121 to 126 characters against a limit of 120. Everything else about those summaries was correct.

Structured output guarantees the shape of the JSON, not the length of the strings inside it. I knew that in theory. 3% scrap on a batch I had already paid for is what it costs to learn it in practice.

The AI summaries are live on the tracker now. Not a local build, not a mock: this is production, grounded on the official release notes only, with a line pointer next to every claim. 126 of them went out in a single batch. Here is what that actually cost me.

View full thread

Real cost: $4.16. I had estimated $2.94. Off by a third. The miss is the interesting part. My median was almost exactly right, $0.0245. My mean was not, $0.033. I had estimated on a tail I had never seen.

Because I measured cost on my eval set: 14 releases I picked by hand, with shorter notes than the real catalog. Hand-picked samples are tidy, the catalog is not. The 10 most expensive summaries are 21% of the entire spend.

The AI summaries are live on the tracker now. Not a local build, not a mock: this is production, grounded on the official release notes only, with a line pointer next to every claim. 126 of them went out in a single batch. Here is what that actually cost me.

The batch: 126 summaries, one submission, done in 17 minutes, 126 of 126 succeeded on the API side. 3 more never got sent at all, because their release notes blow past my 80k character limit. Those cost nothing: they fail before the model ever sees them.

The release tracker has accounts now. You follow the tools in your stack, and one page tells you what shipped in the last 30 days and whether upgrading needs work. Here is what I learned building it. 🧵

View full thread

Real data found what my tests could not. The compact row said latest major for TypeScript, which has 7 releases and no major recorded, because the import stops at 12 months. And a beta sorts above the stable before it, so a preview stood in for a technology.

The release tracker has accounts now. You follow the tools in your stack, and one page tells you what shipped in the last 30 days and whether upgrading needs work. Here is what I learned building it. 🧵

View full thread

Public pages stay static. The Follow button on a public technology page runs in the browser, and an end to end test downloads the HTML served to a logged in user and checks it contains no Following. Nothing personal can be cached by mistake.

The dashboard: a verdict, then Spotlight, Latest, Previews and a weekly digest folded by ISO week in UTC. Digest releases are never summarized, so a week can only ever say: no action needed. Open weeks live in the URL, so the view survives a reload.

The release tracker has accounts now. You follow the tools in your stack, and one page tells you what shipped in the last 30 days and whether upgrading needs work. Here is what I learned building it. 🧵

View full thread

The bug the first real refusal found: I had one mutation per row, so the error stayed inside the component that was clicked and the row that had to show it never saw it. One mutation for the page, and the count, the block and the refusal can no longer disagree.

At 10 of 10 the other buttons get aria-disabled, not disabled: still reachable by keyboard, with a note that says the remedy. Playwright refuses to click an aria-disabled control, so the test has to force it. That refusal is the proof the block is real.

The release tracker has accounts now. You follow the tools in your stack, and one page tells you what shipped in the last 30 days and whether upgrading needs work. Here is what I learned building it. 🧵

View full thread

Concurrency detail I almost shipped without: two tabs inserting at the same time both count nine and both pass. The trigger takes an advisory lock per developer before counting, so the tenth follow is decided once.

The refusal crosses the wire as a code, not as a sentence. The trigger raises two SQLSTATEs of my own, FLIMT for the limit and FRTRD for a retired technology. PostgREST carries them to the browser, so the row knows which rule it broke.

The release tracker has accounts now. You follow the tools in your stack, and one page tells you what shipped in the last 30 days and whether upgrading needs work. Here is what I learned building it. 🧵

The Free plan follows up to 10 technologies, and that limit is a Postgres trigger, not a check in the UI. A limit enforced in the interface is a suggestion: a second tab, a stale page or a direct API call walks straight past it.

The tracker now runs on its own. At 02:00 UTC a cron asks npm and GitHub what is new; today it found Vite 8.3.1 and the public page updated itself. Nobody pressed anything. 🧵

View full thread

The trap: "new" is not "missing from my database". The initial import stops at 12 months, so that rule would have made the first detection swallow the entire history it had just excluded. New = published after the newest release I already have.

The tracker now runs on its own. At 02:00 UTC a cron asks npm and GitHub what is new; today it found Vite 8.3.1 and the public page updated itself. Nobody pressed anything. 🧵

View full thread

Detection is expensive if you do it naively. An npm packument is 1 to 5 MB compressed (Vite's is 4.6). With ~40 technologies that is ~80 MB a day to download and parse inside a 300s function, almost always to learn that nothing changed.

So I keep the ETag on the source and send If-None-Match. npm answers 304 with 0 bytes. Measured locally against the real registry, 3 technologies: first run 3.4s and ~8 MB, second run 0.8s and nothing downloaded at all.

The tracker now runs on its own. At 02:00 UTC a cron asks npm and GitHub what is new; today it found Vite 8.3.1 and the public page updated itself. Nobody pressed anything. 🧵

Vercel Hobby gives you 100 cron jobs, each limited to one run a day. I had read that as "one cron a day" and designed a single orchestrating route around it. It is three instead: detect at 02:00, submit at 05:00, collect at 11:00 UTC.

Three hours apart, because a Hobby cron only promises to start within 59 minutes of its slot. If the order slips anyway, each phase picks the work up on the next run. And a summary is ready the same day instead of 24 hours later.