Back to work

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.

Role
Sole designer
Product decisions to deploy
Scope
Design system, 30 screens
4 flows running
Timeline
2026
Self-initiated, ongoing
Stack
React, Vite, Tailwind
Supabase, Vercel
Product Design Product Strategy Design System Shipping
0ratings, stars or averages, by design and by schema
1design system built for this product, behind all 30 screens
4flows running in production on a public repo

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.

2 familiesGeneral Sans for titles and film names, Geist for everything read
375 × 812Base frame, 4 columns, 16px margins
8pxVertical rhythm, every value a multiple
1 accentBlue, never decorative: it means act, active or new
Colour tokens from the UI kit: surfaces, borders, text and the accent in its four variants, each with its hex value and usage
Colour tokens. Surfaces get lighter as they rise, and the accent has a separate text variant because the base blue fails AA for small text on this canvas.
Type scale: five General Sans display sizes and six Geist body sizes, each with size, line height and weight
The type scale and its rule: never General Sans in a paragraph, never Geist in a film's name.
Button variants, primary, secondary, outline and ghost, each tagged as in use, with pressed and disabled states
Buttons, each variant tagged with whether the product uses it. A disabled button says what is missing instead of going quiet.
The watchlist row component: poster, title, year, director, runtime, a forum activity signal and the saved note
The watchlist row, the component the whole app depends on. Five pieces of data without crowding.
Two product components: posts collapsed for spoilers with a Show action, and the paired note with a hollow node for before and a solid node for after
The components no generic kit has: the collapsed spoiler, and the paired note, the one element with a shape of its own.

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.

3 surface levelsNo elevation shadows

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.

Fill vs text tokenAA at 13px

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.

Two familiesRule by meaning

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.

1Open the list 2Filter by attribute 3See what moved 4Open the film
To watch tab: films ordered by activity, each row with a forum signal in blue and the saved note
Running.To watch, ordered by activity. The blue dot is the only colour on the screen, and it says where there is something new to read.
Watched tab: an amber reminder about films watched but not annotated, and rows marked unannotated, pair published or private note
Running.Watched. Each row says what happened to its pair, and an amber reminder, never red, nudges toward annotating.

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.

No starsNo averagesNo column to fill

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.

Attribute filtersNo sort by quality

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.

Forum-scopedNamed source

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.

1Save with a note 2Watch it 3Write the second half 4Choose what to publish
Save sheet: a field for the note before watching, tags, and the forum thread the film came from
Designed, not yet built.Saving with a note. The sheet records where the film came from, which is what later lets its pair show up in the right forum.
Mark as watched: the earlier note read-only with a lock pill, a second note being written, and two publishing switches
Running.Marking it watched. The design file drew both publishing switches on; the shipped build starts them off, for the reason below.

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.

Postgres triggerNot a UI guardHolds past the client

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.

Single updateConstraint on final state

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.

Optional by designNo empty placeholder

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.

1Open a thread 2Read what is open 3Expand deliberately 4Or opt out of the film
Thread with a notice that the film is unwatched, open posts, and one post collapsed with its author and a Show action
Running.A thread for someone who has not seen the film. The collapsed post shows who wrote it and why it is closed, and nothing of what it says.
Reply composer with a required question, does this reveal something, and a publish button that names the missing choice
Designed, not yet built.Replying. The spoiler question is required, and the button says what is missing until it is answered.

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.

Omitted from the queryNot display:noneVerified in the response

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.

Author visibleReason visibleBody withheld

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.

Amber, not redCheap exit first

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.

1Follow forums 2Open a film 3Read pairs from those forums
Film page: your own note, then conversation from three forums you follow, with no score anywhere
Running.The film page. Your own note first, then what the forums you follow are saying, scoped and named.
Before and after section: pairs from other people, each with a forum badge, a hollow node for before and a solid node for after
Running.Published pairs, each carrying the forum it came from. That badge is the whole answer to why this stranger's opinion is here.

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.

Explicit provenanceExplainable filterNo scoring

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.

Same condition as threadsOmitted, not hiddenNo new mechanism

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.

RLS on all tablesViews made invoker-sideFound in review

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
The production deployment card showing the live domain, a ready status and the source commit on the main branch
Production deployment. Every push to the main branch rebuilds and redeploys.

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.