##  [No cat in the bag: 10,000 product descriptions under scrutiny at 60fps](/node/293)

    *Submitted by Lennart on Tue, 6 Oct 2026 - 08:55*

 *Ten thousand product descriptions in one table scrolling at 60 frames per second. Then the table got a language model that writes suggestions and a judge that evaluates them. It's all built in Nushell, and every change is logged as an event.*

![Catify's logo: a reddish-brown cat looking up from a blue shopping bag with a price tag.](/sites/default/files/2026-10/00-logo.png)Catify: a cat in a bag with a price tag.I had built Topify, a tool for working with Shopify catalogs. It grew. It got readability reports, style guides, and language checks, and eventually, it was hard to say what it was actually for.

So I asked Claude Code to start over with only one thing in focus: the product catalog. This became Catify, a cat in a bag with a price tag. The name comes from "catalog," and the cat was hard to avoid.

The requirements were few. The table had to be fast, even with ten thousand products. The server had to control it. And a description had to be editable in a large popup that only shows the description.

## A table where the browser doesn't own the data

Catify runs on cross.stream: xs is the event log, http-nu is the web server, and everything is written in Nushell. The browser receives HTML from the server and is updated with Datastar over Server-Sent Events. It has no copy of the catalog.

This might sound like a bad recipe for a fast table. The trick is that the browser handles the scrolling itself. The page has an empty block that is as high as ten thousand rows. The server only sends the 150 rows you are close to, and when you approach the edge, the page asks for the next ones.

We tested with Kith's public catalog, 10,000 products and 62 MB of JSON. A window of 150 rows takes 2-5 milliseconds to generate on the server. In the browser, we measured 60 frames per second and no blank rows, both at 200 and 1,000 rows per second. If you edit a description in one tab, it is updated in another tab 27 milliseconds later.

![Catify's table with Kith's products and the columns LIX, Struct, AQ, and New.](/sites/default/files/2026-10/01-oversigt.jpg)Kith's 10,000 products with LIX, Struct, and AQ. The Wilson racket at the top has a pending suggestion (New).The columns to the right in the table are what make it more than a list. LIX is readability. Struct measures how easy the description is to skim. AQ is an assessment of whether the description answers the customer's questions, and New shows a suggestion waiting to be reviewed.

## A search field that understands `price > 1000`

The search field can also filter. You can type `structure < 40 items = 0 price > 300 type ~ jacket` and mix it with regular keywords. Catify translates this into SQL with a fixed list of allowed fields, and the values are never directly inserted into the query. Fields that don't exist are displayed as a red message, and the rest of the filter still works.

![](/sites/default/files/2026-10/02-filter.jpg) 300 type ~ jacket and 235 results." data-caption="Filters and free text in the same field: 235 jackets over 300 without a list and with weak structure." width="1019" height="579" loading="lazy" /&gt; ## Structure measured with a real HTML parser

Shopify stores descriptions as HTML, and it's often messy. The structure index uses `nu_plugin_query`, Nushell's plugin for CSS selectors, so it's a real parser reading the HTML, not regular expressions. The index gives 40 points for a list, 30 for headings, and 30 for paragraphs under 60 words.

This revealed something that readability scores don't capture. Almost all of Kith's descriptions are lists where each line is its own paragraph. They look nice, but the HTML has no structure, and 9,098 out of 10,000 score below 40. At the Danish store Gumpel, the median is 88. The calculation for all ten thousand takes two seconds.

## A judge you can rely on

AQ comes from jev, TypeSafe's System One. It evaluates on a four-step scale describing concrete situations, from "nothing answered" to "almost everything answered with concrete details."

We started with two evaluations. The second was meant to measure if the text overcomes purchase objections. It correlated 0.92 with the first, so in practice, it measured the same thing, and we removed it.

The evaluation that remained is stable. We ran 39 products three times. The average difference was one point, and no products changed categories. Whether the evaluation is correct is another question, and only the store owner can answer that.

Therefore, Catify has a calibration. The owner reads descriptions as the customer sees them and answers one question with four buttons. Jev's score is hidden in the meantime. After 20 answers, Catify shows how much the owner and jev agree and where the line between a good and a weak description should be drawn. The owner can put the threshold into use with one click.

![The calibration view with a watch description and four answer buttons from Nothing useful to Everything.](/sites/default/files/2026-10/06-kalibrering.jpg)Calibration: the store owner assesses the description before jev's score is shown.## Worksets with two tracks

Batch editing starts with a filter. Two default sets cover most cases: `task = create` is for products without descriptions, and `task = improve` is for descriptions rated as weak. You can also select by product type or build your own filter.

When you freeze a set, it is saved as an event with a fixed list of products. This is important because otherwise, the filter would move under you: if you fix a weak description, it falls out of `aq < 50`. Each track has its own instruction for the language model, and it can apply to the set alone or to the entire store.

![A frozen set with ten watches and two text fields with instructions for the create and improve tracks.](/sites/default/files/2026-10/05-set-instruktioner.jpg)A frozen set with an instruction for each track.Some rules the instruction cannot change. The language model can only use the product's own data, and it cannot invent materials, measurements, or claims. It writes in the store's own language and imitates the descriptions the owner has marked with a star as role models.

## Suggestions to be approved

The language model never changes a description itself. It writes a suggestion, which jev evaluates, and the suggestion waits in the New column. In the popup, the old and new text are displayed side-by-side, rendered as the customer sees them. You can switch to the HTML source and make corrections before approving.

![Popup with the current description on the left and the suggestion on the right, AQ 78 and 76.](/sites/default/files/2026-10/03-side-by-side.jpg)Wilson racket: the old text and the suggestion side by side, both rendered.The Wilson racket is a good example. The old text has AQ 78, and the suggestion has 76. So, the content is equally good. The difference is the structure: the suggestion has proper lists and headings, where the original had a paragraph per line. Whether it's worth changing is up to the owner, but now you can see what you're choosing between.

The Changes tab shows the difference word for word.

![The Changes tab with deleted text marked in red and new text in green.](/sites/default/files/2026-10/04-changes.jpg)The Changes tab shows the difference word for word.At Gumpel, three suggestions for UV shirts raised AQ from 35, 36, and 45 to 51, 49, and 67. The language model found the sizes in the product's options and invented nothing. It still needs to be read before approval. A suggestion for a pair of ski goggles with two lenses added "so you can switch according to conditions." This is not in the product data, and such things are easier to catch when the two texts are side-by-side.

## What it requires

The source code is Nushell and a few templates. A read model in SQLite unfolds the event log into tables, and it can be discarded and rebuilt. This happened many times along the way without anything being lost.

Most of the work consisted of measuring before we decided on anything, and of testing on a separate copy of the data, so the real sets and evaluations were not messed up. A single error cost more time than others. In Datastar, `null` in a signal means the signal should be deleted, not that it is empty. So, it said "AQ" without any number.

The next step is to send the approved descriptions back to Shopify.