Self-initiated · Film community app
Matinee. A film app with no ratings.
Designed and shipped end to end by one person: product strategy, a design system, thirty screens and a working build in production. Including the decisions that only show up in the database.
What this is
Every film app ranks films. A number tells you a film is a 7.8 and tells you nothing about whether it is for you. I wanted to build the opposite: a watchlist at the centre, and around it, the only signal I think is honest, which is what the people in the forums you chose to follow are actually saying.
That single decision cascades. With no score, the list needs other reasons to be interesting. With no algorithm, affinity has to come from somewhere the user picked themselves. With conversation as the main signal, spoilers stop being a nuisance and become the central design problem.
I made every one of those calls. I built a design system for the product, from tokens to the components only this app has, and drew thirty screens on it. I defined the data model and took it to production. The sections below start with the system, follow the four flows that run today, and end with what I chose not to build and what I will not claim.
Scope, stated plainly
Where my hands were, and where they were not
A portfolio claim should survive being questioned, so here is the division of labour before anything else.
Mine, entirely
The product thesis and every product decision. The information architecture. The design system, tokens and all thirty screens. The data model, including which rules live in the database rather than in the interface. The review of every implementation decision, the ones I rejected included. The deploy, the environment, and the security posture of the running app.
Written with an agent, under my direction
The React implementation was written with Claude Code in the terminal while I directed, reviewed and corrected it. I am a designer who ships, not a front-end engineer, and the interesting evidence here is not authorship of lines. It is the quality of the calls: three of the four flows below exist in their current shape because I overruled the first implementation proposed to me.
The system
A design system built for this product
Before the final screens, I built the system they would be drawn on: colour, type, grid, elevation, every component with its states, and a voice. It is dark-first with no light mode, because the app is used at night and is full of posters the interface does not control. So the base is a cold desaturated grey, and colour appears only where there is something to act on or something new.
Elevation by luminance, not shadow
On a near-black canvas a shadow is invisible. Hierarchy is built with three progressively lighter surfaces, documented as levels rather than improvised per screen.
The accent exists twice
The saturated base blue fails AA for small text on the dark canvas, so there is a lightened variant reserved for text and links while the base is reserved for fills: the activity dot, the primary button, the active tab.
Type mixing is semantic
One family for titles and film names, another for anything read continuously. The rule is about what a string is, not how big it is, so a reader learns to recognise a work without reading the words.
Iteration
Before
The first two batches used 20px side margins on a 393px frame, with vertical spacing set per screen.
After
375px frame, 16px margins, every vertical value a multiple of 8, and type sizes referenced from the kit instead of written inline.
Why. 20px breaks an 8px base, and the narrower frame cost the watchlist row 32px of usable width, which is exactly the test worth running before thirty screens are drawn at the wrong measure. I also documented the full button matrix while marking which variants the product uses, which is far fewer than the reference I started from. An exhaustive kit that outruns its product is debt, and a kit honest about its own coverage is a better argument than a complete one.
1Flow · The watchlist
The list is the product.
Two tabs, To watch and Watched, and a row of filters that sort by attribute rather than by rank. The only dynamic signal on a row is conversation: how much is being said about this film in the forums you follow, right now.
No score, not even stored
There is no rating field in the schema. Keeping the column out means the product cannot quietly grow a ranking later without a deliberate migration and a deliberate argument.
Filters describe, they do not rank
Under 100 minutes, slow, unannotated, has new activity. Each filter answers a question about your own situation rather than telling you which film is better.
The signal is named, not scored
A row says three new in Horror, not a number out of ten. The unit of interest is a conversation in a forum you chose, so the signal carries the forum's name with it.
2Flow · The paired note
What you expected, next to what you got.
The format the product is built around. You write a note when you save a film, and another when you mark it watched. The first one freezes at that moment and can never be edited again, which is the only thing that makes the pair worth reading.
The freeze is a database trigger
Once the row is watched, any write to the earlier note is discarded and the old value restored. Enforcing it in the interface would make it a suggestion. The promise the format makes to a reader has to hold even against a direct call to the API.
One write, not two
A constraint forbids a second note on a film that is not marked watched. The status change and the note travel in the same update, so the row is never momentarily invalid and the flow never half-commits.
Half a pair is still allowed
If a film was saved without a note, the screen drops the pair layout entirely and shows one field at full width. The format is the highlight of the product, not a toll gate on marking something watched.
Iteration
Before
Both publishing toggles were drawn switched on, so marking a film watched would publish both halves by default.
After
Both start off. Publishing each half is a separate, deliberate act with its own explanation underneath it.
Why. My own product document says a note is born private and publishing is always explicit and reversible. The mockup contradicted it. The deciding argument was the sequencing: the earlier note freezes at exactly the moment the toggle would have fired, so a default-on switch would have taken away editing and privacy in the same tap, neither of which the user asked for.
3Flow · Spoilers
Collapsed, not blurred. And never sent.
If conversation is the main signal, protecting people from it is the main constraint. Flagging a spoiler is mandatory when posting, collapsed posts show their author and their reason but not their text, and the one escape hatch is telling the product you do not plan to watch the film.
The text never reaches the browser
A collapsed post is not hidden with CSS. The body field is omitted from the query, so the spoiler is absent from the response rather than present and covered. Anyone who opens developer tools finds nothing to read.
Collapse, because blur invites squinting
A blurred block is a dare, and a half-legible word is worse than a hidden paragraph. Collapsing keeps the thread readable and makes expanding an actual decision rather than an accident of scrolling.
Correction is collective, not punitive
Readers can flag a post that should have been marked. The notice leads with the cheap exit, leave it marked, so the easy path is the one that keeps the queue small. Moderation copy never uses red: in almost every case this was forgetfulness, not misconduct.
4Flow · Affinity through forums
Whose opinion you see, and why.
With no algorithm and no social graph, something has to decide which notes are worth showing you. The answer is the forums you chose to follow. A published pair appears on a film page only if it came out of a thread in a forum you subscribe to, and it carries that forum's name so the reason is visible.
Provenance is a column, not a guess
Each pair records the thread it came from, which resolves to a forum. Affinity is therefore a join, fully explainable, with no model and nothing to tune. A pair with no origin thread simply does not qualify.
Second halves inherit the spoiler rule
A film page shows reactions from people who have seen the film, to someone who may not have. The second half of every pair sits behind the same condition as a thread, and is left out of the query when it does not apply.
Access rules on views, not only tables
Row-level security was on across every table, and off on two database views that read from them. A view runs with its owner's rights unless told otherwise, so the protected tables were readable through it. Caught in review before launch and closed.
Iteration
Before
The proposal was to drop the forum filter and show every published pair, because the seed data had no origin threads and the filter returned nothing.
After
The filter stayed and the data was fixed. Three lines in the seed script gave the pairs their origin threads.
Why. The filter is not a feature of this screen, it is the product's entire answer to the question of why you should care about a stranger's opinion. Remove it and the section becomes notes from nobody in particular. The empty result was a gap in test data, not a signal about the design, and I asked what it would cost to fix the data before agreeing to cut the idea. One pair was deliberately left without an origin thread, because a pair with no known provenance is a real case the code has to handle.
What I decided not to build
Three decisions where the interesting part is what I gave up, and the argument against each one.
No downvotes
Threads lose prominence through inactivity instead. A downvote is a cheap way to express disagreement and an expensive one to live with, because it teaches people that unpopular readings cost something.
Against it: decay is slow and indiscriminate. A thread that deserves to sink stays visible for a while, and without a signal of disapproval, moderation absorbs work that a community could have done itself.
No open forum creation
Forums are the unit of affinity, so an unbounded list dilutes the only mechanism the product has. Proposals go through a review queue in version one.
Against it: it puts me in the way of my own users and it does not scale past a founder with time. If the product ever grows, the queue becomes the bottleneck and the rule has to change.
No direct messages, no following people
You follow forums and films, never persons. It keeps the product about works rather than personalities, and removes a whole class of harassment surface before it exists.
Against it: people are the reason communities stick. Taking away the ability to find someone whose taste you trust gives up real retention for a cleaner principle.
The outcome
What runs, and what I will not claim
Running in production
- ✓Authentication and sessions
- ✓Watchlist, both tabs and filters
- ✓Film page with published pairs
- ✓Threads with collapsed spoilers
- ✓Mark as watched and the paired note
- ✓Public repository, deploy on every push
Designed, not yet built
- ○Onboarding
- ○Search
- ○Profile
- ○Forum directory and composer
- ○Moderation queue
- ○Save with an opening note
There are no numbers in this case because there are no users. This is a self-initiated product with a test account and seeded data. Every iteration above is reasoning argued against my own product document, not a result measured on anyone. I would rather say that than dress a decision in a percentage.
What I would measure first. Whether a saved film ever gets its opening note, since a pair is impossible without one. The share of watched films that end up annotated at all. And how often a reader expands a collapsed spoiler, which would tell me whether the collapse is protection or an obstacle.