From a working prototype to merged UI
On Hanlun’s DSE practice platform I designed the student and teacher apps, built a working Next.js prototype of them, and then contributed frontend code to the production React and TypeScript apps. This page follows that path through three of my merged pull requests. The product side is in the Hanlun product case.
The prototype was where behaviour got tried; the production apps were where my pull requests landed, merged by the team’s engineers. Each change below names the problem, my scope, what it was built on, the states it had to handle, how it was checked, and its trade-off.
Behaviour you can’t judge from a static frame
Paper 1 of the DSE maths exam is answered by hand. On screen that means a writing canvas that inks for a pen or mouse but not for a resting hand, maths that looks like the printed paper, and answers that survive leaving mid-paper. Whether those work is a question of behaviour, so they went into a working prototype early, where they could be tried in a browser rather than imagined from frames.
7. Solve x2 − 5x + 6 = 0. (3 marks)
2 / 7A pen writes. So does a mouse.
Between the design file and the codebase
A new feature went from Figma to the prototype, then into the production codebase, where I built its UI and states as Storybook stories and in the student and teacher apps, and opened a pull request for an engineer to review and merge.
- MeDesignflows and screens in Figma
- MePrototypea working Next.js app, Cursor-assisted
- MeBuildUI and states as Storybook stories and in the apps
- MePull requestopened by me, with screenshots
- TeamReviewscreens reviewed in the thread
- EngineerMergeafter CI passes
- Pull requests
- 92 opened and 81 merged, February to October 2026, merged by three engineers on the team.
- Screen review
- In the three pull requests below, screens were discussed from screenshots in the thread, and requested changes landed as follow-up commits before merge.
- Where my code went
- Mostly the student and teacher apps, their shared component package and Storybook; a few small changes to the API alongside them.
Two codebases, kept apart
The prototype was a single Next.js app with the student and teacher views switched by URL. Production splits them into two Vite apps that share a component package and an API client generated from the backend’s schema.
- Prototype
- Next.js 16, React 19, Tailwind CSS, Radix UI. December 2025 to September 2026.
- Production
- React 19 on Vite 7, TypeScript 5.9, Tailwind CSS 4, Radix UI and shadcn/ui, TanStack Router and Query, Vitest, Storybook 10, over a Python backend.
- Shared
- A component package used by both apps, so a reminder or a lesson card is written once; and a typed API client, regenerated when the backend adds a field.
- Afterwards
- The prototype stayed in use as a reference. In September 2026 an alignment audit against the production teacher app, and an inventory of prototype features not yet in production, were committed to the prototype under my name.
Three pull requests, reconstructed
Each one below is a merged pull request of mine from 2026. The code and screens are the client’s and stay private, so each demo is reconstructed for this page with synthetic data to show the behaviour the change introduced.
The passing rate an attempt was judged at
Teachers can change a quiz’s passing rate, and a new rate applies only to future attempts. Another engineer built the backend that stores each attempt’s rate at submission. My change exposed that stored rate to both apps and put it beside every attempt.
Reconstructed demo · synthetic data
- Attempt 1Fail45%
- Attempt 2Pass55%
{ score: 55, passRateAtSubmit: 50 } — the latest attempt as both views receive it. Future attempts are judged at 50%; earlier ones keep their own rate.
- Trigger
- The ticket asked for each submitted attempt to show the passing rate it was judged at, now that rate changes are future-only.
- My scope
- The stored rate added to the student and teacher API responses, mapped into the view models, and shown on lesson cards, the attempt list, the result page and the teacher’s quiz tab, with copy in both languages.
- Built on
- Another engineer’s backend change, which stores the rate at submission and makes rate changes future-only. An engineer regenerated the typed API client before merge.
- States
- Pass or fail always visible as a pill, never only in a hover tooltip; the rate paired with the submission time; attempts before and after a rate change each showing their own rate.
- Checked
- View-model tests for both apps map attempts “with their snapshotted passing rate, not today’s rate”, so a later rate change can’t rewrite history on screen. A lesson-card test checks the rate sits inside the score pill with no tooltip. In review a teammate asked for the rate on both result views; a follow-up commit added it. Merged after CI.
- Trade-off
- One placement everywhere, next to the submission time, instead of an inline chip on some screens and a hover-only tooltip on others.
Reconstructed outline · not Hanlun’s code
How the stored rate reaches the screen
- Engineer
The backend stores the rate on each attempt at submission.
- Me
The student and teacher API responses include it on every attempt summary.
- Engineer
The typed API client is regenerated from the backend schema.
- Me
Each app’s view model maps it onto the attempt, tested against today’s rate.
- Me
The pill and “rate · submission time” render the same way in both apps.
A reminder only iPads see
On iPad, a resting hand or the system’s Scribble handwriting could interfere with annotating. The change shows a dismissible reminder, on iPad only, on the student’s answer page and the teacher’s marking page.
Reconstructed demo · synthetic content
Writing with Apple Pencil? In Settings › Apple Pencil, turn on Only Draw with Apple Pencil and turn off Scribble, so a resting hand doesn’t mark the sheet.
iPad: the reminder sits above the sheet on both handwriting pages, in Traditional Chinese or English.
- Trigger
- Annotation interference on iPad. The team settled on asking users to turn on Only Draw with Apple Pencil and turn off Scribble.
- My scope
- An iPad check, a reminder component in the shared package used by both apps, copy in Traditional Chinese and English, and Storybook stories for the reminder and the teacher’s marking page.
- Built on
- The existing annotation pages in both apps and their shared component package.
- Edge cases
- iPadOS Safari reports itself as a Mac, so the check also looks for a touch screen; laptops never see the reminder; the check is safe where there is no browser; a dismissal lasts for the browser session, so it doesn’t come back on every page.
- Checked
- Approved by an engineer before merge. The wording changed in review, once the team agreed users should also turn off Scribble.
- Trade-off
- The pull request calls it an interim fix: it asks users to change a device setting instead of changing how the canvas handles touch.
One theme per subject
Each subject has its own brand colour, and a student or teacher can switch between courses. The change set up a theme system: the selected course’s subject picks a palette, and both apps read the same few colour variables.
Reconstructed demo · this page’s own colours
Maths brand #C9A227 · button text dark: 6.1:1 passes AA (white would be 2.4:1)
- Trigger
- A ticket for subject colour themes in Storybook, which the change carried through to both apps.
- My scope
- A shared theme provider driven by the course’s subject, seven palettes with tints derived using
color-mix, a Storybook theme toolbar, contrast-check stories, and seed data for checking several subjects locally. - Built on
- The apps’ existing selected-course state and the shared component package.
- Edge cases
- A subject with no mapping falls back to the default theme; two reserve palettes stay in Storybook until a subject claims them; switching course changes the colour variables for the whole page.
- Checked
- The pull request sets out what the change affects and where reviewers should look. Contrast checking led to adjusting one palette to meet WCAG AA. Merged by an engineer.
- Trade-off
- The colour variables are set for the whole page, so switching course recolours every surface in both apps at once. The pull request lists that reach for reviewers.
Working with Cursor, and checking what it wrote
Cursor was the AI coding tool on Hanlun. This is AI-assisted development of an ordinary web app; nothing in this case integrates an AI model into the product.
- The record
- Cursor is credited as co-author on 68 of my 167 merged production commits and on 135 of my prototype commits, including every non-merge commit of mine in the three pull requests above. A co-author credit marks that Cursor was involved in a commit; it doesn’t measure how much of the code it wrote.
- What I set
- Project rules and skills for it: rules in the prototype, and a skill in the production repository that drafts pull-request descriptions with Storybook preview links.
- A correction
- Cursor’s built-in browser froze the editor on the heavy Paper 1 canvas screens. A rule committed under my name now blocks it there: the agent finishes with preview links instead, for a person to check those screens in a real browser.
- Team checks
- Storybook stories for the states, unit tests with the logic, screens discussed in the pull request, and an engineer merging after CI.
- Volume
- The prototype has 346 commits, 345 of them mine. In production I made 179 commits, 167 merged to main through 81 pull requests. Counts show where the work went, not its quality.
The pieces every screen is built from, with their tokens and stories.
0d02d9202/06finalize StatusTag component with serif font and refined colors4757d1902/13Submission confirmation UI and design tokens naminga57954004/17webAdd Paper1 fullscreen view and shared marking toolbar61c0a1a07/21web, dbAdd subject-based brand color themes9c64d2207/31webAdd status-error and status-submitting tokens for exam PDF status chips
Real commit subjects from my history, ticket numbers removed. Outlined days are when this kind landed; hover a line to find its day.
What carries over
Turning a design into a working prototype brings interaction and implementation constraints to the surface earlier, while they are still cheap to change.
AI-assisted coding speeds up iteration, but the UI and behaviour it produces still need human judgment and checking before they ship.
The portfolio is a working app too
Every interactive figure here is reconstructed in React rather than embedded as a video or image, including the three demos above.
- Stack
- Next.js 16 App Router, React 19, TypeScript, hand-written CSS.
- Access
- Cases under NDA sit behind a password checked on the server. A request proxy serves their pages only to unlocked browsers, so locked visitors never receive the cases’ code or data.
- Shipping
- GitHub Actions runs lint, type checks and a production build on every push; Vercel deploys.
- AI-assisted
- Built with Claude Code: most of this repository’s commits are co-authored with it.