Decisions
Every important choice made while building Vektos, grouped by theme, with why it was made, what was rejected, where to read the story, and whether it held up.
The journal tells the project in order. This page is the same project by theme: what was decided, why, what else was possible, and whether the decision survived. Read the journal to see how it felt at the time; read this to see what is true now.
Each entry has the same shape: the decision, why, instead of what, and where it is told. "Held up" says whether later work confirmed it, refined it or reversed it. Chapter numbers (ch. 17) are the journal's; guides and anecdotes are linked.
What changed our mind
Most of the decisions below were right the first time. These were not, or were right only after a correction. They are the most useful to read.
| Before | After | What changed it | Where |
|---|---|---|---|
| One grader per kind of mistake, counted in full | A compiler error that restates a design-system violation is flagged, not counted | The quality column was 96% one error code, and it was the same mistake counted twice | ch. 17, anecdote 08 |
| Use Storybook's MCP server as the agent's context | Our own ds-mcp, with Storybook's kept as a level to compare against | Storybook's server documents components but has no token table, so an agent using it cannot know tokens exist | ch. 4, anecdote 04 |
check_code runs the design-system graders | ds-mcp-check-full also runs the compiler and the linter | The checker took one column to zero and left the other alone: "checked half" | ch. 15, anecdote 07 |
| A grader flags any string that looks like CSS | Only values of style properties | rate: "12%" in fixture data was graded as a raw value | ch. 3, anecdote 03 |
| No breakpoints, one font, spacing to 8 | Breakpoints, sans/mono, more steps, letter spacing | Writing the dashboard hit the same walls as the agents did; stored pages showed what they reached for | ch. 18, 19, 21, anecdote 09 |
| A hybrid search is the best search | Hybrid candidates, then a reranker; the hybrid alone is worse than vectors | Measured on four embedding models: merging with equal votes lets the weak list pull the strong one down | ch. 24, anecdote 10 |
| A tiny section joins the one before it | A document can opt out (min-words: 0) | 19 WCAG criteria were merged into their neighbours, so a citation pointed at the wrong one | ch. 25, anecdote 11 |
| Search for the rule behind an accessibility violation | Look it up from axe's own tag; search is the fallback | The checker already names the criterion: 7 of 7, against 3 of 7 for search | ch. 25, guide 16 |
| The server keeps only the text of the history the browser sends | The browser sends one question; the history, with tool results, comes from the database | Saving conversations made the server the owner of the history; reading it back is both safer and cheaper (no repeated tool calls) | ch. 27, ch. 30 |
| Two regulatory PDFs (the plan) | HTML and XHTML, converted to Markdown | Both texts are structured in HTML; a PDF would mean guessing the headings back from a layout | ch. 25 |
Components accept a className, joined after the recipe's | Governed components: no className, layout props only, UNSAFE_* counted | A className that set whiteSpace was silently beaten by the recipe; the toolchain saw nothing | ch. 35, anecdote 12 |
The quality column to hold quality only | It still holds render problems | Dropping them would hide a page that crashes after mounting, which "done" does not show | ch. 17 |
How the work is done
- A branch, a PR and a rebase merge for every block; never
main. Why: each block is one idea that can be read and reverted alone. Instead of committing tomain. Where: ch. 0. Held up: every block so far. - No ticket tracker. Why: one person, and the roadmap in PLAN is enough. Instead of Linear or GitHub issues. Where: ch. 0. Held up.
- One concern per commit, single-line semantic messages. Why: the history reads as a list of changes, and one can be reverted without the rest.
- CI on every pull request, with no secrets.
check,check:dband bothnext builds on one Linux job. Why: the checks had only run on one Mac, andchecknever built the app (anecdote 15). No key in CI, so it cannot spend. Instead of checks on a laptop only. Where: ch. 39, guide 20. - Docs ship with the feature. Every capability has a guide (What, Why, Try it, Extend it, Known gaps) in the same PR, and every block has a journal chapter. Why: documentation written later is written from memory. Held up: the guides are where the "Known gaps" that became the next blocks were first written down.
- A mistake that teaches something becomes an anecdote. Why: the lesson outlives the fix, and it is the part worth telling. Held up: twelve so far, and several changed the design.
- No real model runs without an explicit go-ahead. Why: runs cost money and the credit is the limit. Set in the later blocks; the one exception was embeddings and reranking for retrieval, approved when they were needed, at a few cents.
Stack
- Bun for everything; never npm, pnpm or yarn. Why: one runtime and test runner. Where: ch. 0.
- TypeScript 7 only (the native compiler), no TS 5 fallback. Why: the spike showed it is
enough. Consequence: it has no compiler API, so code is read with
oxc-parser. Where: ch. 0. oxlintandoxfmt, replacing Biome. Why: fast, and oxlint takes the Panda rules as a plugin. Where: ch. 0, guide 02.- Panda CSS with
strictTokens, components on Ark UI. Why:strictTokensturns "follow the design system" into a type error, which a grader can count. Where: ch. 0, 1, guide 01. Held up: it is the premise of the whole benchmark. - The
postgresdriver and Drizzle 0.45, not Bun's SQL and not Drizzle 1.0. Why: Next runs on Node, and the stable line is enough for these tables. Where: ch. 10. - Postgres with pgvector from the first day. Why: retrieval needed the extension and changing the image later means moving data. Held up: used in ch. 24.
- The MCP SDK v2 packages (
@modelcontextprotocol/server). Why: it is the current line.
The design system
-
A token's name carries its job (
bg.*,fg.*,border.*). Why: a grader can seebg: "fg.muted"from the name, with no colour maths. Where: ch. 1. Held up:token-roles. -
Palette colours exist only to feed the semantic ones. Using
brand.700in a component ignores the theme, so it is a violation. Where: ch. 1. -
Components never draw their own focus ring. One global rule does. One less thing to forget. It also covers a drawn control (
data-focus-visible) whose real input is hidden. Where: ch. 2, 36. Where: ch. 2. -
Selecttakes anoptionsarray, not child components. Fewer ways to hold it wrong.RadioGroupfollows. Where: ch. 36. -
Form controls declare their own props.
onCheckedChange(checked: boolean), not Ark's details object; each control brings its label. Why: the simpler the shape, the fewer ways an agent holds it wrong, and the MCP can say "no other props". Where: ch. 36, guide 06. -
Feedback speaks the tones of
Badge.Alertandtoast()taketone, and each tone has its own icon shape. Adangertoast stays until closed. Where: ch. 37. -
Only waiting moves. Two animations,
spinandpulse, both paused under reduced motion; no transitions elsewhere. Where: ch. 37, guide 01. -
A library's mechanics live in the config. Zag's toast variables are raw values; they sit in
globalCssnext to the focus ring, not in a component with an escape hatch the grader would count. Where: ch. 37. -
A module with an import-time side effect is a client module.
toast.tsxsays"use client", so the barrel stays safe for server components. Where: ch. 37, anecdote 15. -
A link is a component, not a global rule.
.vektos a:not([class])is gone; text links areLink, and a native<a>is anative-elementviolation. Why: a design system that styles bare elements makes its component optional, for agents too. Where: ch. 38. -
The router comes in through a provider.
RouterProvidertakesnavigateandprefetch;Link,BreadcrumbandPaginationuse it and stay real links. Instead of: depending onnext/link, orasChildon every link. Where: ch. 38, guide 06. -
A component's own props come from its interface. The grader reads every
<Name>Propsin the design system instead of a hand-written list of CSS-like names (size,content,page). Where: ch. 38. -
Compound components: an array where it is enough, governed parts where it is not.
Select,RadioGroupandTabstake an array (options,items).MenuandAccordion(DS-6) hold free content in each item (icons, separators, sub-menus), so they get parts (Menu,MenuTrigger,MenuItem…) built inside the design system with Panda'screateSlotRecipeContext, and exported throughGoverned<>types. Why: that helper, which Panda generates instyled-system/jsxand@pyck/reactbuilds every component with, solves a real problem: a variant set once on the root (size="sm") reaches every part through a context, with no props passed by hand, and each part is one line. It also wraps every part instyled(), so each part acceptsclassNameand every style prop: right for a published, open library whose apps restyle it, wrong for a governed one, where that surface is what the benchmark measures agents misusing. So we take the context and leave the openness: the internal parts come from the helper, the exported types removeclassNameand style props, andjsxMatchTagstill limits what Panda reads. Instead of an array for everything (too rigid for a menu) or@pyck/react's open parts (restyling from outside). Where: ch. 42 (the reasoning), DS-6 (the use). -
The design system formats numbers and dates.
FormatNumberandFormatDatetake raw values and a locale fromLocaleProvider. Why: amounts were formatted by hand in every task and page, and a generated UI needs it most. Where: ch. 41. -
A component's own props never use a forbidden name.
FormatNumber's option isformat, notstyle. Where: ch. 41. -
Heading level and size are separate. The outline is for screen readers, the size for the eye; one
level={1}per page. Where: ch. 41. -
A checked control is mint with an emerald edge. The fill alone fails the 3:1 a control's boundary needs; the edge is
accent.text, a border, which its role allows. Where: ch. 36. -
Recipes stay private. Agents get components, not the styles behind them.
-
The CSS reset is scoped to
.vektos. Why: installing the design system must not restyle the host page. Where: ch. 0. -
Close a gap in the design system, not in the grader. An agent that writes
mdwas right to expect a breakpoint. Where: ch. 19. -
Add a token only with evidence: stored pages, or the dashboard needing it. A spacing value an agent used once (
7) did not justify a token. Where: ch. 19, 21. -
A changed design system is a new version. Each batch records a hash of it; the history flags rows built from more than one. Where: ch. 10, 18, 19.
-
Change the palette, keep the names. The colours follow Practical UI (navy-tinted greys, one light cobalt accent in both themes); the token names stay. Why: graders, tasks and saved runs all speak today's names, and changing both at once would hide which change moved the results. Instead of: Practical UI's names (
text.strong,fill.*), which became a planned experiment. Where: ch. 31, guide 01. -
Mint and ink, after a preview. Mint accent in both themes, green-tinted neutrals, an ink header and table heads. Chosen from side-by-side previews (three rounds of three), not from words. Why: five rounds of describing "a warmer blue" missed. Where: ch. 33.
-
An ink region is a scoped dark theme.
bg.inverseplus thedarkclass on the element, instead of five or sixinverse.*tokens. Why: the text, links and buttons inside already have dark-theme colors; one token keeps the vocabulary agents read small. Where: ch. 33, guide 01. -
Governed components, like Spectrum 2. No
className, nostyle: a component's own props and a short list of layout props. Why: a page that restyles a component makes the design system a suggestion, and aclassNamethe recipe silently beats is the worst case (anecdote 12). Instead of: merging the caller's class (anyone can restyle anything) or acssprop (the same, typed). Where: ch. 35, guide 06. -
One list, three readers.
layout-props.tsfeeds the types, Panda'sjsxMatchTagand the grader, so the compiler, the stylesheet and the benchmark cannot disagree about what a layout prop is. Where: ch. 35. -
Long logical names, no height.
marginInlineStart, notmsormarginLeft: one name per idea, right-to-left for free. A component is as tall as its content. Where: ch. 35. -
Stack and Grid instead of
divs. Arranging content was the most common reason to put a class on aCardBody; two primitives give it a governed home. Parts take no layout props. Where: ch. 35. -
Layout defaults live outside the recipe.
InputandTableare full width throughcss.raw, merged with the page's layout in onecss()call, sowidthreally replaces it. Where: ch. 35.
Counting mistakes (the graders)
- What the design system says, the grader counts. When
accent.defaultstopped being a text colour (ch. 31, 33),token-rolesstill accepted anyaccent.*anywhere, so only axe on the rendered page would notice, as quality. The grader now knows the accent's jobs, and an outline (a focus ring) is its own role, apart from a border that continues a fill. Where: ch. 34. - Restyling a component is a design-system mistake; the way out always costs.
outside-stylingcountsclassName,styleand non-layout style props on@vektos/dscomponents;UNSAFE_*counts every time andvektos-ignorecannot silence it. The compiler'sTS2322on the same line is covered, matched by line (no style call to match by). Where: ch. 35, guide 03. - Three groups, not one score: design system, quality, completeness. Why: a native
<button>, a crash and an empty page are different failures, and one number hides which context helped with what. Where: ch. 8. - Only style properties are graded. See "What changed our mind". Where: ch. 3.
- Hiding a mistake is never free.
vektos-ignore,@ts-ignoreand lint disables are counted themselves. Why: otherwise an agent could pass by sprinkling disable comments. Where: ch. 3. - The design system and the app grade themselves. A test runs every grader on
apps/weband the design system; our own code must be clean or the graders are wrong. Where: ch. 3, 18. - An empty page must fail. The graders count mistakes, so doing nothing scores perfectly.
Each task lists texts the page cannot avoid showing (
mustShow): names and labels, not amounts, because€4,821.37and4.821,37 €are both right. Where: ch. 5. - Lint drops two rules (
prefer-token,no-primitive-token) because they restateraw-valuesandtoken-roles. The same idea, applied to the compiler later. Where: ch. 8, 17. - By call, not by line, and only
TS2769. Same-line matching covered 81%; by call, 91%. The other compiler errors are real mistakes the design-system graders cannot see. Where: ch. 17. - Keep the violation, flag it. Deleting would lose the evidence and make the rule impossible
to change. A flag can be recomputed (
bench:recount). Where: ch. 17. - Style calls only for
token-names, and prefer a miss to a false alarm. A grader that cries wolf costs the benchmark its trust. Where: ch. 20. - No backfill. Old runs were made against an older design system; grading them with today's token table would make them look better than they were. Where: ch. 20.
- axe in both themes. A token that passes contrast in light can fail in dark (anecdote 02). Where: ch. 8.
- System Chrome, not a downloaded browser. Nothing to download, and the checks do not depend on the Chrome version. A visual diff will need a pinned Chromium; the steps are in guide 11. Where: ch. 8.
Context levels, tasks and the runner
- The prompts never mention the design system. What the agent knows about it is the thing that varies between runs. Where: ch. 5.
- Ten tasks, the new five chosen by component. Each new task needs one family the first five
never asked for (form controls, feedback, navigation), so a context that teaches them shows up
in the results. Labels that
mustShowexpects are quoted in the prompt. Where: ch. 40, guide 08. - Every reference solution passes the page checks, in Chrome and both themes, in
check:page. Why: a task our own answer cannot pass cleanly would count against every agent. Where: ch. 40. - Five context levels:
none,storybook-mcp,ds-mcp,ds-mcp-check,ds-mcp-check-full. Each adds one thing, so a difference has one cause. Where: ch. 4, 15. - The agent cannot see the repo. Its files are in memory, with only the fixture; the context level is the only way it learns about the design system. Where: ch. 7.
- Tasks are plain files (
task.json, a prompt, fixtures, a reference solution). A team on its own design system must be able to add one without writing code. Where: ch. 5. - Fake models first. The whole loop was testable, free, before any paid call. Where: ch. 7.
check_codeis off by default. An agent that can grade itself is a different experiment, so it is its own column. Where: ch. 4.- Markdown answers from the MCP, not JSON. Same information, fewer tokens. Only semantic colours are listed, and the maintainer sections are left out. Where: ch. 4.
- The MCP server is started by file path.
ds-mcpdepends onbenchforcheck_code; the reverse import would be a cycle. Where: ch. 7. - The budget stops tasks, not steps. Cutting a task would leave a half-written page with a partial cost. Where: ch. 9. A budget per batch for the most expensive model (Kimi). Where: ch. 16.
- One pass for the new models, not three. Learn whether the order holds before paying for repeats. Where: ch. 16. Open: repeats for the cells that have one run.
- The runner stays on the AI SDK, because we checked. Its requests were compared with the
same loop written with the official Anthropic and OpenAI SDKs. The model reads the same words;
only the format and a few defaults differ. Snapshots fail
bun run checkif an upgrade changes the wire. Instead of moving the runner to the official SDKs on the belief that an abstraction rewrites the prompt. Where: ch. 32, guide 19, anecdote 13. - The provider seam waits. Isolating the AI Gateway behind a
packages/modelsinterface is in the PLAN, after this check, with no new keys and no database migration. Where: ch. 32.
Cost
- Cost is the benchmark's second metric, not a product. No budgets, forecasts or billing connectors: that market is crowded. Where: PLAN §2 and §8.
- Prices are never edited, only added. A new price is a new entry with a start date, and a run is priced with the entry valid the day it ran. Otherwise last month's runs would get cheaper. Where: ch. 6, guide 09.
- Price each request, then sum. Some models charge more per token above a prompt length; summing first would push a run of small requests into the expensive tier. Where: ch. 6.
- Cached tokens are their own line. A cache read costs a twentieth of an input token on the newest Claude models, and an MCP tool list is sent with every request. Where: ch. 6.
- Model ids are the gateway's ids, so one string picks the model and its price. Where: ch. 6.
- Check the bill in code, on every run. The first check was by eye against a dashboard and matched to four decimals, which cannot tell $0.100 from $0.125. The gateway returns its own number in every response. Where: ch. 9.
- Free models are for debugging only, never for a comparison. Where: ch. 9.
Data: the database, the report and the dashboard
- Provenance first. A batch records the git commit, whether the tree was dirty, and a hash of the design system, so a number says what it was measured with. Where: ch. 10.
- Violations are rows, with their group; silenced ones are kept and flagged. Where: ch. 10.
- Real runs save by default and refuse to start without the database; mocks do not save. A paid run is never lost; pipeline tests do not become history. Where: ch. 10.
- The files are written before the save, and a failed save stops the batch.
results/is the safety net; continuing would cost money for runs missing from the history. Where: ch. 14. - Backup tools run inside the container (the Mac's
pg_dumpis older than the server), and restore never overwrites: it makes a new database. Backups live outside the repo. Where: ch. 11, guide 12. - The report is one file with the screenshots inlined, everything a model wrote is escaped, and meaning is never colour alone. Where: ch. 12.
- The dashboard is server components with filters as links. No client state to get wrong, and a filtered view is an address. One hand-written chart, hidden from screen readers because the table beside it has the same numbers. Where: ch. 18.
- The dashboard does not import the runner, so the AI SDK and Playwright stay out of the Next bundle. Where: ch. 18.
- Every name is a link to the run list with that filter, through one helper, so the same name always leads to the same place; a link carries the filters already set (a batch label, the filter of a chat tool). Instead of a page per model or per rule. Where: ch. 29.
- A link looks like one: underlined, in a color made for text.
accent.textwas added afteraccent.defaultfailed contrast onbg.subtlein dark mode. Where: ch. 29, guide 01. - No trend chart yet: every stored batch has the same hash, so a line would be flat. Where: ch. 22. Open.
Generative UI
- Our own renderer, not json-render. A model composes a view as one nested JSON object
(
{ type, props, children }); each node is checked against the catalog's zod schema and rendered with the design system; what fails is left out and reported, the rest renders. Why, measured in the spike on the same ten-component catalog and the same free model (ch. 42): 3 of 3 generations rendered against 1 of 3; a prompt of about 1,000 tokens against 4,200; rendering on the server, so the browser downloads nothing more (json-render'sRendererneeds a client component: about 197 KB more JavaScript on the page); props checked at render, where json-render passes unknown values (tone: "purple") through. About 100 lines of code. Instead of Vercel'sjson-render, which is stronger where Vektos does not need it yet: state, actions, data binding andrepeatfor interactive UIs. Its prompt teaches all of that even to a read-only answer, and a free model spent its effort there (two specs pointed their root at an element that did not exist; one wrote amounts as template strings, past the formatter). Where: ch. 42, anecdote 16. - A generated view is read-only. The catalog has no button, form or dialog: an action needs a safe way back to the app, which is a decision of its own. Where: ch. 43, guide 21.
- A spec is bounded. Links go only to paths of the app (no
https:,//host,javascript:); at most 12 levels and 400 parts; strings have a maximum length. Why: a spec comes from a model, so from text anyone can influence. Where: ch. 43. - The catalog's enums equal the components' own, checked by the compiler. Assignable is not
enough: a tone the catalog invents, or one it misses, fails
tsc. Where: ch. 43. - Postgres 18 everywhere. The laptop and CI moved from 17 to 18 (
pgvector/pgvector:pg18), as Neon runs it. Why: a dump made and restored by the same major version, on every machine. Where: ch. 71, guide 12. - Chat forms run the app's actions, and only open pages. A form in a chat view must name a
registered action (
open-runs,compare-runs); the app builds the address from the values and opens it in a new tab. Why: forms became useful in the chat without the model ever choosing where data goes. Where: ch. 69, guide 17. - Done includes behaviour (graders v3). Five tasks list what the page must do after the user
acts (
behaviour.json); a failure is a completeness violation and the run is not done. An error counts when it is tied to its field (aria-invalidor the browser's own check). The files are in the graders' hash, not the tasks'. Why: "done" meant "shows the right text on load", which an inert form passes. Where: ch. 68, guide 11. - A product's components are a context, measured against primitives. Five warehouse
composites in the catalog, offered with
{ domain }and run asgenui-spec-domainnext togenui-spec. Why: whether higher-level components help a model compose a page is a question, not a given. Where: ch. 67, guide 21. - A domain is a task attribute first.
"domain"intask.json, banking by default so old hashes hold; words in the spec prompt per domain; one difficulty panel per domain. No domain catalog or rules until a domain needs them. Why: the warehouse needed no new component. Where: ch. 66, guide 24. - A view may point at its data, at a level of its own.
{ "$data": "/pointer" }resolves against data the host gives; the spec keeps the pointer.genui-spec-dataoffers it with the task's fixture;genui-specdoes not, and the chat does not yet (its data comes from tool results). Three reruns showed it helps Beam and costs Nemotron, so it is measured as a context, not made the default. Why: a bound value cannot be mistyped, and a table is one line instead of its every cell. Where: ch. 65, guide 21. - The spec mode checks a whole page; the chat does not.
validateSpec(…, { page: true })asks for one level-1 heading, first, and headings in order. Why: Nemotron left the title out of seven views and only the page check noticed (anecdote 23). Where: ch. 64, guide 21. - What a checker answered is stored. Steps keep the answers of
check_codeandsubmit_view. Why: two spec runs ran out of tries and nobody could say why. Where: ch. 64. - The context is part of a run's version. Each batch records a
contextHashof the files that shape what the agent receives at its level (CONTEXT_SOURCES), backfilled from each batch's commit. Why: chapter 62 changed the spec mode's prompt and catalog, and nothing recorded it. Where: ch. 63, guide 23. - Generated forms are opt-in, and send nothing. The catalog has six form components, offered
only with
{ forms: true }(the benchmark's spec mode, not the chat); a form is drawn as<form method="dialog">, so the browser checks required fields and stops. Why: four more tasks can be answered as a view, while where data goes stays the host's decision. Where: ch. 62, guide 21. - Task difficulty is read on a panel. Only the series (model, context, version) that ran every task count, each weighing the same, and so does each task within a series. Why: the first five tasks were also run by the paid models, which made them look easier (anecdote 22). Where: ch. 61.
- CI checks the app on made-up runs.
db:seed-demowrites fixed fake runs into an emptyvektos_demodatabase; CI starts the built app on it and runscheck:app. Why: a page can build and still break, and a public dump would put real runs (and their size) in CI. Where: ch. 60, guide 20. - Graders v2: axe's best practices count. A page without
<main>, without an<h1>, with headings out of order is a quality violation. All ten reference answers already passed them. Why: the page check meant to run them since week 1 (anecdote 19); the grader hash now keeps old and new apart. Where: ch. 59. - A version is the design system, the graders and the output cap. Each batch records all
three;
graderHashis new and was backfilled from each batch's commit. Why: a number that moved because the grading changed is not a regression of the agent. Where: ch. 58, guide 23. - A change counts when the repeats do not overlap. Two versions are compared on their shared
tasks;
worseorbetterneeds non-overlapping batch ranges and a floor; one batch on each side gives no verdict. Why: the spread of repeats is often larger than the effect. Where: ch. 58. - A new repository for the rewritten history. The old one became
vallo87/vektos-private; GitHub keeps every pull request's commits there, with the old address, which a force-push would not remove. The PR numbers in the journal before chapter 57 are that repository's. Where: ch. 57. - Run the benchmark on
main, after the merge. A batch records the commit it ran on; a rebase merge gives a branch's commits new identities, so two batches pointed at commitsmainnever had. Where: ch. 57. - The public repository keeps the project, not the person. The plan's goals, business risks, packaging and asset table moved to a private file outside the repository; the thesis, stack, architecture and roadmap stay. All rights reserved: no licence. Where: ch. 56.
- A deploy gets a copy of the database without its secrets.
db:export-publicleaves out the rows ofauth_account,auth_sessionandauth_verification. Where: ch. 56, guide 22. - 20,000 output tokens a step, stored with the batch. Every normal step in the stored runs wrote under 19,000; the runs at 32,768 were loops. Why: a cap is part of the conditions a run is compared under, and a loop on a paid model is money. Where: ch. 55.
- The provider seam is
local/<name>over OpenAI's API. One branch inresolveModelreaches Ollama, LM Studio, vLLM or a hosted compatible API, free and without the gateway. Why: a vendor-neutral benchmark must not depend on one gateway, and local models cost nothing. Where: ch. 55. - A spec run is graded by the code run's grader.
gradeRuntakes what any run left in its workspace; a spec run leavesspec.jsonand a page that draws it. Why: the comparison is only fair if both are judged by the same code, render and axe included. Where: ch. 54. - The spec mode runs only the read-only tasks. Why: a view cannot be a form; asking for one would measure the catalog's gaps, not the approach. Where: ch. 54.
- The free rerun: two free models, every task, five contexts, three repeats, one commit. 300
runs under the label
free-rerun, all on design-system hashcdb5d9e7and commit5ec4b02, none dirty. Why: the design system changed for the last time before it; one version makes every new run comparable. Where: ch. 53. - A finding on free models has no cost ratio. A median of nothing is null, not 0. Where: ch. 53.
- The page check runs WCAG 2.0 and 2.1, A and AA; nothing else. It always did: the
best-practicestag in its options matched no rule. The tag is gone and a test checks that axe knows every tag. axe's realbest-practicewaits for a new grader version, so stored and new runs are counted alike. Where: ch. 52, anecdote 19. CardTitleis an<h2>. A card is a section under the page's one<h1>; an<h3>skipped a level on every page. Where: ch. 52.- A renderer without CSS reads
@vektos/ds/raster. Satori draws the share preview from inline styles; the design system gives it its tokens' values, resolved, and an image's pixel sizes. Why: the dogfood check bans raw values in the app, rightly; the values belong to the design system. Where: ch. 52. - Examples for visitors are shared conversations, not fixtures. The owner shares a real
conversation; the visitor reads it from the database, drawn by the live chat's own components.
Why: one feature (sharing) gives both, and an example cannot drift from what the chat really
does.
chat:examplesmakes three for a new database. Where: ch. 51. - A run's code marks the lines that broke a rule, and each violation links to its line. Why: the run page is where a reader checks a grader's claim; the claim and the code it is about should be one click apart. Where: ch. 49.
- Sorting is a whitelist in the query.
?sort=names a key ofRUN_SORTS; the SQL fragment comes from a fixed map, never from the address. Where: ch. 49. - The overview leads with a sentence computed from the data.
mainFindingcompares no help with the context that cuts violations most, among the contexts the most models ran, and gives the median cost ratio. Why: the page showed every number and said nothing; a reader should get the result before the table. Where: ch. 48. - One heatmap, one metric at a time, instead of two tables. Why: the matrix of badges and the long table repeated each other; a metric switch keeps one grid. Where: ch. 48.
- Fields take and give plain values.
DatePickertakes and returnsYYYY-MM-DDstrings,NumberInputa number ornull,Comboboxa string ornull: Ark'sDateValueobjects and string numbers stay inside. Why: a page, an agent or a generated view writes a date as text. Where: ch. 47. - Where Ark's accessible names hide the visible text, the design system writes its own. The date picker's period button ("November 2026: choose a month"), its month cells (full names), the upload's drop area. Why: WCAG 2.5.3, found by axe. Where: ch. 47.
MenuandAccordionare built withcreateSlotRecipeContext, behindGovernedPart<>. The helper gave the size context as promised. Its parts read style props, so the exported types also drop HTML attributes named like one (color,translate,content). Where: ch. 46.Draweris aDialogon one side, not Ark'sDrawer. Ark's is a sheet with swipe and snap points; a side panel needs none of it, and the design system does not animate. Where: ch. 46.- A hand-written
<details>is a native element with a component. Thecomponentsgrader maps it toAccordionorCollapsible; the app's own chat had one. Where: ch. 46. - The identity stays mint and ink. Three alternatives were built as Panda themes and compared on the real pages (Ledger: warm paper and indigo with IBM Plex; Signal: zinc and violet with Geist; a calmer Mint with Inter). All passed WCAG AA; the user kept today's palette. Why: continuity, and the problems were density and noise, not colour. Where: ch. 45.
- Table heads are a quiet tint, not ink; links in tables are
plain. Why: a black band over every table and an underline in every cell made the dashboard heavy. A link in running text keeps its underline, the only thing that tells it from the words around it. Where: ch. 45. - A view in the chat is accepted, not just shown.
show_viewtakes it only if every part renders and every number is one a data tool returned in the same answer; one correction, then words. Why: the chat already checks citations and asks for numbers from tools; a view is where a wrong number would look most authoritative. Where: ch. 44, guide 17. - The tool's input is the root node, with a plain schema. Why: measured,
{ view }with an opaque or recursive schema came back as a JSON string a third of the time; the node as the input came back as an object every time. Where: ch. 44, anecdote 17. - Every input passes the tool's door; refusals are tries. An input refused by its schema is
retried by the model without end; refused by
execute, it counts against the two tries. Where: ch. 44. - What we take from json-render: streaming that renders as the JSON arrives (ours repairs the
partial JSON with the AI SDK's
parsePartialJson), a prompt generated from the catalog, and the idea of sending the issues back to the model for one repair. Where: ch. 42. - A generated view is checked by what it shows, not only by its schema. Where: ch. 42, anecdote 16.
Retrieval
- No framework. Chunking, retrieval and reranking by hand: they are the parts worth being able to explain. Where: ch. 23, guide 15.
- Questions are not worded like the headings, and a hit needs the right document and the right text (and, for a law, the right heading). Where: ch. 23, 25.
- A tenant on every row from the first table. Isolation is a stated risk; a test checks it. Where: ch. 23.
- Ingest is idempotent on the function too. The hash includes the chunker's version, after an ingest said "unchanged" when the chunker had changed. Where: ch. 23.
- One vector per chunk and model, no fixed size. Models differ (1,024, 1,536, 2,560 numbers), and keeping them apart made comparing four models a loop. Where: ch. 24.
- Embedding is a separate command from ingest, because it calls a paid model. Where: ch. 24.
- Rerank is the default search;
lexicalstays for when there is no key. Where: ch. 24. - Tests never call a model. A fake embedder and reranker test the plumbing; the evals, which cost cents and run by hand, measure the quality. Where: ch. 24.
- The cheapest of the equal rerankers is the default. Three were within noise. Where: ch. 24.
- The converted laws are committed, so the evals read the same text everywhere. Where: ch. 25.
- The Publications Office, not EUR-Lex, because EUR-Lex answers a script with an empty 202. Where: ch. 25.
The chat
- Typed tools, no free SQL. The plan names "SQL tools" (
compare_runs,get_violations…) and leaves open whether the model writes the SQL. It does not: it fills the parameters of the dashboard's queries, so a prompt cannot make it read or change anything else. Instead of a sandboxed SQL tool. Where: ch. 27. - The spending cap is a table, checked before the model is called. Every step and search is a row; a request is refused if the sum plus a reserve could pass the cap, and a step that reaches the cap ends its request. Instead of a promise, or a counter in memory that a restart resets. The limit was set at $1 by the user. Where: ch. 27.
- The dearer of our price and the gateway's is what counts, so the cap is never under-counted by a lagging price table. In 24 real steps the two were identical. Where: ch. 27.
- The server keeps only the text of earlier messages. A client can forge a tool result in the history, and the model would take it for real. Follow-ups call the tools again. Where: ch. 27.
- A citation is a ref a tool returned, and the page says when it is not. Provenance, not truth: the guide says so. Where: ch. 27, guide 17.
- Numbers come from tools and are shown as the tool's table, so a sentence can be checked against the table above it. Where: ch. 27.
- The endpoint is a factory (database, model and search passed in), so production and tests differ only in what they pass, and no test calls a model. Where: ch. 27.
- Costs reach the model as text it can quote (
"$0.0126") as well as numbers; it had rounded $0.0126 to "about $0". Where: ch. 29. - Say why when the chat cannot answer. A missing gateway key is a 503 with the fix in the message, and the page disables the form and shows it. A generic "try again" sent the reader to retry something that could never work. The real error of any other failure goes to the server's log. Where: ch. 28.
- Only the chat is behind sign-in, with GitHub and an allowlist, chosen by the user. The list is checked when an account would be created (a stranger leaves nothing stored) and on every request (a name taken off works at once). Instead of anyone signing up, or the whole dashboard behind a login. Where: ch. 30, guide 18.
- Better Auth, not a homemade session, with its tables generated by its CLI and kept as our migrations, telemetry off, the GitHub token encrypted, and every route but GitHub's disabled. Where: ch. 30.
- The history comes from the database. The browser sends one question; the server reads the rest with the tool results it produced. This replaced dropping tool results from the browser's history, and follow-ups no longer call tools again. Where: ch. 30.
- The conversation id is the browser's, ownership the server's; someone else's id is a 404. Where: ch. 30.
- A cap per person inside the chat's cap: $0.50 of the $1 by default. Where: ch. 30.
- Work with the design system's components, not against them. An override by class does not work (anecdote 12); the starter buttons got short labels. Where: ch. 27.
Open decisions
Nothing below is decided. Each is a known gap in a guide.
- Is the regulatory corpus shared between tenants? It sits in the
vektostenant today (guide 16). A client's chat needs its own documents and the shared ones. - A vector index (
hnsw) per model, which needs a fixed vector size, once the corpus has tens of thousands of chunks (guide 15). - A link from a design-system violation to the law. Only axe's violations have a WCAG number; the route for the others is the harmonised standard (EN 301 549), which is not in the corpus.
- Repeats for the cells with one run, and more models. Waiting for the go-ahead to spend (results).
- A pinned Chromium for visual diffs (guide 11).
- Authentication for the dashboard; today it is open to anyone who can reach the port (guide 14).
- Production backups: a managed Postgres, a nightly dump to a bucket and a monthly restore drill (guide 12).
- A trend chart, when there are batches on two design-system versions.
- Deleting one's own account from the interface, and exporting conversations (guide 18).
- Checking the model's sentences against the tool's table, not only its citations.
- Who sets the output ceiling. The runner sets no
maxOutputTokens, so the AI SDK sends the model's maximum direct and the gateway picks its own (anecdote 13). - Does a page's
divwithdisplay: flexcount, now thatStackexists? Not today (guide 06). - Count
<h1>or<p>written by hand whereHeadingorTextexist? Not yet: it would fire on every page (guide 06). - Count
next/linkwithoutLink? It renders an unstyled link today and is not counted (guide 06). - Render agent pages through Next too? The render check bundles for the browser only, so a page that breaks as a server component passes (anecdote 15).
- A layout prop with a value Panda cannot read renders without its style and is not counted (anecdote 14).
The label that pointed nowhere
The first behaviour checks failed a model that had done the right thing: the design system's date picker had no label inside a form field, and axe had never noticed.
Journal
The build log of Vektos, written as a tutorial. Read it top to bottom to see what was built, in what order, why, and how to try each piece yourself.