Skip to content
mp.
All writing
Engineering

Building an app people can actually use

An art gallery website sounds like a collection of pictures. Building HOME/HOMELAND, an international student art initiative I help organize, meant thinking about the people on both sides of those pictures.

Artists need to submit work. The project team needs to review it. Visitors need to browse the exhibition. Those are connected tasks, but they should not all have access to the same information.

I built the site with React, TypeScript, and Supabase. The framework choice explains very little about whether an artist can finish a submission or a visitor can still see a picture after leaving the tab open. Those are the details I want to pay attention to when building an app.

Follow a piece of work through the system

I find it useful to describe the path before talking about components:

Artist prepares a submission
  → enters information and provides artwork
  → sends it for private review
  → project team decides what to publish
  → visitors browse the published work

Each transition raises a practical question. What survives a refresh? What happens if the file is too large? Does a successful submission mean the artwork is already public? Which fields belong in the gallery, and which are only for the team?

This gives the interface a job. The form helps someone complete a submission. The account page helps them understand its state. The gallery presents work that has reached publication. A route existing in the app is not enough; the transitions between routes need to make sense.

Decide what can be recovered

The submission form saves a local draft with a timestamp. That lets someone return to the form without starting every text field again. The draft has a retention window, and invalid or expired timestamps are rejected.

There are limits to that convenience. Browser storage can fail. A saved filename does not restore the original file upload. The form needs to make those distinctions clear instead of implying that every part of the submission has been saved.

For uploads, the project checks an explicit list of file types and a size limit before sending the file. That gives the artist useful feedback early. Client-side checks are not a security boundary, though: the backend and storage rules still need to enforce what is allowed.

One small test checks the boundary rather than only a normal upload. This condenses two assertions from the gallery’s existing Vitest suite; the imported values and function belong to its submission module:

import { expect, it } from 'vitest';
import { MAX_ARTWORK_FILE_SIZE, validateArtworkFile } from '@/lib/submission';

it('accepts the size limit and rejects the next byte', () => {
  expect(validateArtworkFile({
    size: MAX_ARTWORK_FILE_SIZE,
    type: 'image/png',
  })).toBeNull();
  expect(validateArtworkFile({
    size: MAX_ARTWORK_FILE_SIZE + 1,
    type: 'image/png',
  })).toMatch(/50 MB/i);
});

The limit in this implementation is 50 × 1024 × 1024 bytes. The test only establishes what the local validator does; it does not upload a file.

I want recovery behavior to be part of the original task. Otherwise it is easy to build a form that works beautifully until someone reloads it halfway through.

Keep publication decisions out of the browser

Hiding the review page is not how I want to protect submissions. Someone can inspect browser code or make requests without using the intended interface.

In the gallery source, row-level policies restrict submission access, and the public gallery query selects work whose status is published. It returns a defined set of presentation fields rather than the entire submission record. Supabase documents how row-level security applies database rules to client requests.

That is a design boundary, not a claim that reading a schema proves a deployment is secure. Testing it properly means trying requests as a visitor, the submitting artist, another artist, and a reviewer. A UI test with a mocked backend cannot establish that the live database denies an unauthorized request.

The distinction matters even in a small project. “The button isn’t visible” and “the operation is forbidden” are different observations.

Think about the second visit

A gallery can work on the first load and still feel broken during ordinary browsing. Switching rooms shouldn’t unnecessarily reload artwork that is already available. Opening a picture shouldn’t prevent its media URL from being refreshed. A late response from a page someone has left shouldn’t overwrite the current page’s state.

Signed media links make time part of the problem. A link can expire while a tab is asleep. Renewing links on a timer helps, but a suspended tab may miss that timer. Returning to a visible tab or coming back online gives the application another opportunity to refresh.

The gallery’s tests exercise those cases with controlled clocks and mocked responses. They also check that a refresh preserves the loaded image and open viewer, that retries are bounded, and that requests stop when the gallery unmounts.

I like these tests because their names describe a visitor’s experience. They explain why the request and cache logic exists. Without that context, it is tempting to simplify away a branch that handles an infrequent but ordinary interaction.

Make the interface work without a mouse

The artwork viewer has a named dialog, an Escape-to-close interaction, and a keyboard-accessible route to comments. These details belong in the component’s behavior, not in a separate idea of polish.

Component tests can check accessible names and dispatched keyboard events. A browser check is still needed for real focus movement, scrolling, and the rendered layout. The ARIA dialog pattern is a useful reference when deciding what a dialog should do.

The same principle applies to language. The gallery uses language-prefixed routes, so a shared URL preserves the language someone was reading. A stored preference should not quietly override the URL someone explicitly opened.

Translation also has a human state that a language selector cannot explain. During this project, I had to ask why reviewed German translations were not back on the site, and whether the files people were reviewing actually contained the latest copy. A translation existing somewhere is different from that version being reviewed and shipped.

Artist-written titles and statements need particular care. The browser suite checks that changing the interface language does not machine-translate the artist’s own text. It also checks Arabic presentation and mobile overflow. Those checks protect specific decisions about the work on display, not just a generic requirement to “support languages.”

Make repeated verification affordable

The gallery’s GitHub Actions workflow runs linting, type checks, a production build, unit tests, and Chromium browser tests. Bun is pinned through the package configuration and used in Vercel’s commands too, so a local runtime update needs to reach the build service.

A push to the same branch cancels an older CI run. That keeps an obsolete commit from consuming the verification queue while its replacement waits. Separate branches can still run independently. GitHub’s concurrency controls define that grouping and cancellation behaviour. The browser download is cached using the runner OS and the exact Playwright version; a cache hit still installs the required system dependencies. A warm cache should save a download without substituting the wrong browser binary. This is a project choice, not a universal recommendation: Playwright’s CI guidance notes that restoring its browser cache can take as long as downloading it.

The browser report and failure artifacts are uploaded even when tests fail, unless the run is cancelled. That makes a failed check something I can investigate, rather than a red status with no useful evidence.

There is an important boundary here: Vercel deploys from the repository separately. A passing local suite does not establish that a deployment succeeded, and a CI workflow running on a push does not by itself block Vercel from deploying that push. Required checks before merge and inspecting the resulting deployment are separate parts of the release process.

The browser suite uses a disabled live backend. It can test navigation, focus, and the submission interface without pretending to verify the production database.

Check each claim at the right level

The project uses Bun to run its existing Vitest tests. These are the focused suites behind the submission, gallery-navigation, and viewer examples in this article:

bun run test src/test/submission.test.ts src/test/gallery-navigation.test.tsx src/test/art-viewer.test.tsx

That command belongs to the HOME/HOMELAND repository; it is not a standalone gallery tutorial. The backend calls in the component suites are mocked. Passing them does not verify live storage permissions, email delivery, or a real upload.

For a new app, I would start with one complete path and follow it through those layers: the input, the stored state, the permission check, and what the next person sees. Then I would try it with an interrupted connection, an expired session, or a keyboard.

Those checks are easier to postpone than adding another screen. They are also much closer to the reason someone opened the app.

More engineering, reflections, and fiction.