A small messaging-app UI built for a frontend take-home. UI only — data, auth, and
actions are mocked/static, per the brief. Focus is design fidelity across mobile (390px)
and desktop (1440px) breakpoints, matching the provided fe-challenge-specs.html
reference.
- Vite + React + TypeScript
- styled-components, with a typed theme (
src/styles/theme.ts) holding every design token — colors, type scale, borders, hard offset shadows, spacing, breakpoint - No UI library — every component (button, input, select, tag, avatar, etc.) is hand-built
- No routing library, no data-fetching library, no tests — intentionally, see Decisions
npm install
npm run dev # start dev server (fixed port 5183)
npm run build # type-check + production build
npm run preview # preview the production buildLog in with the pre-filled seeded account (ada@dispatch.dev, any password) — auth is
mocked, so any input works.
- Login — responsive split layout (yellow intro panel + form), controlled inputs, mocked submit
- Feed — header, sticky filters sidebar (desktop), sticky compose (desktop), message list
- Compose — textarea with 240-char counter, tag picker (custom dropdown), disabled Post until there's content; posting actually prepends a message to the feed
- Filters — multi-select tag chips, user select, native date range picker,
clear; fully wired against the mock data (tag, user, and date range all filter the visible list live) - Message item — avatar/handle/time/body/tag, author-only inline Edit (textarea
- counter + Save/Cancel) and Delete (inline confirm), both fully interactive
- Empty state — appears for real whenever the active filters match nothing (try tag
RANDOM+ userPriya Shah) - Loading state — a timer simulates an initial "fetch" (~900ms) so the skeleton placeholders are actually visible on load, not just a static mock; a small "Reload feed" link re-triggers it on demand
- Pagination — desktop shows an explicit Load more button; mobile scaffolds
infinite scroll with an
IntersectionObserversentinel. Both simulate the same loading delay (skeletons appended below the existing list) before revealing the next page
Where the reference was silent or ambiguous, here's what I chose and why:
NOWreference captured once at load (src/lib/time.ts) — relative timestamps ("2m", "18m", "1h") are offsets from a single fixed point, not a live-updating clock, so they read identically for the whole session regardless of when the app is opened. It's anchored to the real current time (not a fixed historical date) so the Date filter stays intuitive — picking "today" matches the messages that read as recent, instead of a mismatch where "2m ago" secretly means some fixed day in 2026.- Multi-select tag filters — the reference showed only one active tag chip at a
time, but multi-select (filtering by several tags at once) is more useful and was
requested explicitly, so
Filterstracks an array of selected tags rather than one. - Custom
Selectcomponent — the "User" filter is a fully custom dropdown (src/components/ui/Select), not a native<select>, so its open state matches the brutalist design system (hard shadow, no OS chrome) instead of falling back to the browser's native listbox styling. - Date range filter uses native
<input type="date">— the reference shows plain placeholder text fields with no picker at all. A real (if unstyled) date picker is more useful than a free-text field that's never actually parsed, so "From"/"To" are native date inputs, fully wired againstcreatedAt. The trade-off: the calendar popup is OS-drawn and can't be restyled to match the design system (a real CSS/ browser boundary), but the input box itself keeps our border/font/focus treatment, and each end constrains the other viamin/maxso the range can't invert. - Message tag badges are always neutral (white) — the reference's example messages
happened to show a yellow "PRODUCT" badge, but that read as demonstrating the
Tagcomponent's "selected" visual state rather than a rule that one specific tag is always accented. Privileging one tag's color permanently in a live list felt arbitrary, so every message's tag badge renders the same (neutral) way. - Mobile filters collapse to a chip row +
⚙button — the reference's mobile artboard shows only tag chips and a gear icon with no documented behavior. I wired the gear to expand/collapse the User + Date + clear controls inline, so it isn't a dead affordance. - Inline edit & delete are fully local UI state — "author-only inline edit and
delete affordances" are built as real interactions (textarea swap-in for edit, a
Cancel/Delete confirm step in place of a browser
confirm()), scoped to component state — there's no backend, so nothing persists across a reload beyond what's already described below for auth. - Simulated network delay — there's no real backend, so both the initial load and
"Load more" fake a ~900ms request with
setTimeoutspecifically so the loading skeletons have a real moment to appear, rather than only existing as an unreachable static state. - Simple auth persistence + routing, no router library —
useAuth(src/hooks/useAuth) persists the mocked "logged in" flag tolocalStorageso a page refresh doesn't bounce you back to Login.App.tsxreflects that in the URL (/vs/login) via the History API directly. A full router (react-router, etc.) felt like exactly the kind of "React/Next.js architecture" the brief asks to leave out for a 2-screen app gated by one boolean. - Sticky Filters + Compose on desktop — both pin at the same vertical offset while
the message list scrolls beneath them. The sticky Compose wrapper covers the strip
above it with the page background (rather than a plain
topoffset) so scrolled messages don't peek through the gap above the pinned card. - Mobile order: Compose → Filters → Messages — matches the reference's mobile artboard exactly. Desktop keeps Filters as a left sidebar; both layouts render from the same three components, repositioned per breakpoint via CSS Grid placement (desktop) vs. plain DOM order (mobile) — no duplicated markup.
src/
├── components/
│ ├── ui/ # generic primitives (Button, TextInput, Tag, Avatar, Select)
│ ├── layout/ # AppHeader
│ └── feed/ # feed-specific composites (Filters, Compose, MessageItem, ...)
├── screens/ # Login, Feed
├── hooks/ # useAuth, useDismissableOpen
├── data/ # mock users/tags/messages + types
├── lib/ # time formatting
└── styles/ # theme, global styles, styled-components typing
Every component follows the same convention: Component.tsx + styles.ts + index.ts.