Skip to content

Repository files navigation

nicolasmelo-portfolio

A portfolio you talk to.

There is no homepage with sections to scroll through. There is a box where you type a question. The page then builds itself to answer that question, and it builds itself again when you ask the next one.

Ask "what have you built" and you get panels about the projects. Say "put them side by side" and the same panels move next to each other. Say "where have you worked" and the projects go away and the jobs arrive. If you do not like an answer, press back and the page returns to exactly what it was.

How it works

The page is a piece of JSON. It says which boxes exist, what is inside them, and which box holds which. Nothing more.

When you ask something, a model does not write a web page. It writes a short list of instructions, like "make a panel called logion-panel", "put this text inside it", "put that panel on the screen". We call that list a transaction.

Then the program does two things with the list. It carries the instructions out, and it writes down how to undo each one. That second part is what makes back work. Going back is not rebuilding the page from scratch. It is running the undo notes in reverse order, which lands on the exact same page you had before.

                 the empty page (just a question box)
                              |
                     you ask something
                              |
                              v
              +-----------------------------+
              |  a model writes the list:   |
              |  in the browser, or on the  |
              |  server, or a plain         |
              |  fallback if no model can   |
              |  be reached                 |
              +--------------+--------------+
                             |
                             v
                  a list of instructions
                             |
                             v
              +-----------------------------+
              |  the program runs them, and |
              |  writes down how to undo    |
              +--------------+--------------+
                             |
                +------------+------------+
                v                         v
           the new page            the undo notes

Because undo is exact, the model is allowed to be wrong. It can try, miss, get sent back, and try again, and the page carries nothing over from the attempts that failed. Not a leftover box, not a leftover setting. lib/runtime/timeline.test.ts checks this by running long random sequences of forward and back moves, comparing the result against a fresh replay every time.

The idea comes from Cordis, a paper by Shi, Zhang and Cui about composing things that change over time. The half about time is what this repository implements, in lib/runtime/. The half about space is not here, because there is only one source of content so far.

The model can propose changes. It cannot change how changes get undone. Keeping those two apart is the whole reason a model is allowed to fail in the first place.

The frame stays put, the answer scrolls. The question box and the controls never move. The area below them is where answers are built, and it scrolls.

It used to clip instead. Nothing past the edge of the window existed, which made composition the only way to fit, which was the point. It also made a long answer silently incomplete: asked what he had built, the page showed four projects out of nine and looked finished. Content that cannot be reached is worse than content that needs a scroll.

The boxes the model is allowed to use are still mostly containers: accordions, tabs, carousels and collapsible sections. It is still told to put the answer in the first screenful and the rest one click away. That is a preference now rather than a wall. The full list is in lib/ui/catalog.ts, and nothing outside that list exists.

The rest of the list is one shape per kind of fact, and each one exists because the answer was worse without it:

shape for what it replaced
Table the same few properties across several things two lists side by side, leaving the reader to line the rows up by eye
Timeline anything dated, a work history above all ten roles as ten panels, which never fitted
Meter a share the content actually states one string reading "TypeScript 98% · Python 2%"
Separator two groups inside one panel a second panel

Adding a shape only helps if the model reaches for it, so that was checked rather than assumed. Asked to compare two projects it built a Table. Asked to walk through the work history it built a Timeline. Asked which languages a repository is written in it built one Meter per language, from the percentages GitHub reported. Separator has not been seen in the wild yet.

The catalog is deliberately much smaller than a component library. Most of what a library like shadcn/ui ships cannot help here: the model writes no event handlers, so every input, select and slider has nothing to submit to; the answer area is the one scrolling region there is, so drawers, sheets and scroll areas have nothing left to do; and the surface is text, so avatars and aspect ratios have nothing to show.

Conversations

The second question is a different problem from the first one.

"Put them side by side" has no subject of its own. It borrows one from the answer already on the screen. For a while the server only received the words of the latest question, so it had to work out the subject from those words alone. That went badly. The word "them" scored against a job description that happens to contain the phrase "save against them at runtime", so a question about arranging four projects fetched an employer instead, and the four projects were wiped to make room for it.

Now every question is sorted into one of three kinds before anything is fetched:

kind what it sounds like what it has to do
replace "where have you worked?" show the new subject, and take the old one off the screen
refine "put them side by side", "show me the commit dates" keep the subject, change how it looks or how deep it goes
extend "compare those with where you worked" hold both subjects at once

Naming a subject settles it. "How does logion work" is a new question, even though "how" usually signals a follow-up.

The server keeps no memory of the conversation, and does not need any. Looking up content is a pure function, so the earlier questions are enough to work out the earlier subjects, and the browser already keeps one question per change. It sends the list along. Questions you have stepped back past are not sent, because they are not on the screen, so they are not what "them" refers to.

Three sets of tests, because they answer different things:

npx vitest run lib/conversation.scenario.test.ts           # the pipeline: lookup, author, undo
npx vitest run components/portfolio/multi-turn.test.tsx    # the browser: does it send what the pipeline needs
RUN_LIVE_MODEL=1 npx vitest run app/api/chat/live-model.test.ts   # a real model: does it obey

The first two run on every commit. The live one is opt-in and stays out of the way, because it needs an API key, it costs money, and a model having a bad day would otherwise fail the build for a reason nobody can fix.

Every conversation test checks the same things on every single turn. The page is still valid. No box is left created but not placed. The outer box is never replaced. And the turn is still one press of back away from the turn before it.

Paying once for the same answer

Generation is the only expensive part of this app: 10 to 75 seconds and a fraction of a cent per question, every time, including the fifth visitor to click the same preset.

So answers are kept. What they are keyed on is the whole point. Keying on the question would be wrong, because "put them side by side" after the projects and "put them side by side" after the employers are the same words and different answers, and the second visitor would be served the first one's page. The key is the whole flow that arrives at the answer:

the question  +  the questions before it  +  the document currently on screen

The document is in there because a transaction is written against it. Its operations name boxes that have to exist, so an answer written for one page is not a smaller answer on another page, it is a wrong one. With the document in the key, a hit is a transaction authored for exactly this state, which is the only reason it can be replayed at all.

The practical consequence is that the hits are the openings: the presets, and the questions people type first. Follow-ups hit only when someone walked the same path from the same state. That is the honest ceiling of the design, and the openings are most of the traffic.

Four things it does not do:

  • It does not keep an answer the model failed to produce. A fallback answer is free to recompute, and keeping it would serve the failure for six hours.
  • It does not trust itself. Everything it hands back goes through the same gate as a fresh transaction, because a cache is an author like any other.
  • It does not outlive the content. Entries last six hours, matching the repository read, because a kept answer can contain a repository's last-push date and that moves.
  • It does not grow without a bound. Two hundred entries, and reading one moves it to the back of the queue, so a preset asked all day is not evicted by a run of one-off questions.

The cache lives in the server process, which on Vercel means one instance at a time and a cold start empties it. Somewhere shared, like Vercel KV, would hold more hits; that is a change of infrastructure rather than of code, and it is not done here. x-flow-cache on the response says hit or miss.

The streaming route is where this matters. The browser tries it first and only falls back to /api/chat, so a cache in the other route alone would have been paid for and never reached. There is a test for that specifically, because it is the kind of mistake that fails silently: both routes look cached, nothing breaks, and the bill does not move.

Run

npm install
npm run dev

You can set three things, and all three are optional:

OPENROUTER_API_KEY=...   # lets a cloud model write the instructions instead of the fallback
OPENROUTER_MODEL=...     # which model. defaults to `openrouter/free`
GITHUB_TOKEN=...         # raises the GitHub read from 60 requests an hour to 5,000

Without them the app still works. It just gives simpler answers.

Which model

openrouter/free picks whatever free model is available. Measured at 19 to 87 seconds an answer, sometimes rate limited, sometimes returning a list that does not parse. A first answer is on screen immediately either way, so the model buys a better answer, not a faster one.

Model choice matters more than the speed numbers suggest, because a list the program refuses looks exactly the same as no model at all. Fifteen tries at the same question, counting how often the result was accepted:

OPENROUTER_MODEL accepted per answer how it failed
mistralai/mistral-nemo about half 45 to 75s boxes nested inside boxes where an id was expected, one reply cut off, one rate limit
anthropic/claude-haiku-4.5 3 of 3 about 10s it did not

The smaller model also tried to delete the outer box on every answer it got right, to change a setting it could have changed directly. The program throws that instruction away, which is why nobody noticed until it was counted.

Which GitHub token

It matters more than it looks once the site is live. Without a token GitHub allows 60 requests an hour per address, every visitor shares the server's address, and reading one repository costs four requests. That is about fifteen repository reads an hour for everyone put together.

The reading happens on the server, never in the browser. It used to happen in the browser, which spent each visitor's own allowance, could not share what had already been fetched, and printed a red error in their console every time GitHub said no.

The model in the browser

It starts loading as soon as the page opens, so it is usually ready before anyone asks anything. It is Llama-3.2-1B-Instruct through WebLLM, which is 672 MB to download, with Qwen3-0.6B (335 MB) tried once if that fails to start.

The bigger one is the default on purpose. Writing a correct list of instructions is the hard part, and the trick that would let a smaller model do it reliably is broken in web-llm 0.2.84. See buildChatRequest.

Two numbers are pinned by tests. web-llm insists on a 4,096 token window, and this app asks for 12,288, because the instructions do not fit in 4,096 and every answer failed quietly until it did. And no model is chosen whose own window is smaller than that, however cheap it looks.

The worker is bundled ahead of time by npm run build:worker. Referring to it inline instead was worse than it sounds: Turbopack did not recognise it as a worker, served the raw TypeScript as a video file, and failed without saying anything.

lib/llm/models.ts records every candidate model with measured numbers and both of its window sizes, because size and release date were not enough to pick by. The previous default won on every published number and could not start at all. npm run verify:models checks that list against web-llm and Hugging Face, and the build fails if that check ever stops running.

Loading is skipped when it would be rude: no WebGPU, data saver turned on, or a 2g or 3g connection. Those visitors get the server instead, and the only difference they can notice is that it takes longer.

Checks

This repository is governed by software-factory. Every rule is both enforced by a check and explained in plain words in docs/rules.md. The answers that generated the repository specific rules are in .software-factory/answers.yaml, and the decisions they imply are written down in docs/architecture-decisions.md.

sf verify     # prove every rule still catches the thing it is meant to catch
sf check      # what is wrong in this repository right now
npm test      # unit and component tests, including the conversations
npm run bench # the speed guard
npm run knip  # finds code nothing reaches
npm run lint  # includes a security plugin

sf verify always runs before sf check, both in CI and in .githooks/pre-commit, because a check that quietly stopped working makes every run after it meaningless. Turn the hook on once with git config core.hooksPath .githooks.

Deploy

The same workflow that checks the code also ships it. The deploy job in .github/workflows/software-factory.yml waits for all three check jobs and only runs on a push to main.

Vercel's own git integration is switched off on purpose. It deploys the moment a commit lands, which is before any check has had a chance to fail.

The build happens on the GitHub runner, and vercel deploy --prebuilt uploads only the built output. The source never leaves the runner and nothing is built twice. Three repository secrets are needed: VERCEL_TOKEN, VERCEL_ORG_ID and VERCEL_PROJECT_ID. They are read from the environment instead of being passed on the command line, which would leave the token sitting in the runner's list of running processes.

The app's own settings (OPENROUTER_API_KEY, GITHUB_TOKEN) belong to the Vercel project rather than to this repository's secrets, because the running site is what needs them. vercel pull fetches them at build time.

Content

Everything the site can say about Nicolas lives in content/portfolio.ts: the jobs, the projects, the studies, the skills and the links.

That file is the only source of facts. The model is handed pieces of it and told it may not invent anything, so whatever is missing from that file simply cannot be said. Adding a job or a project means editing that one file. The renderer does not change.

About

My portfolio builds itself as you talk with it. It is aligned to your taste and whatever you feel comfortable

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages