Field Notebook № 01 · Solvara

Wayfold

A weekend three people can actually agree on.

A mobile trip planner for a small group: name the trip, gather everyone's preferences, rank a shortlist together, then walk the day it produces. What follows is the critical slice — six screens and three edge states — built as a running prototype rather than a picture of one.

The Solvara harbour photograph used as the Trip Brief hero inside the Wayfold prototype.
Solvara — the one fictional coastal city the whole slice is set in.
Role
Product design & front-end build
Platform
Mobile app · 390 × 844
Component system
Radix UI + custom gesture layer
Status
Visual acceptance passed at round 3, on all 32 public states

The problem

Group trips break at the moment of choosing

Preferences arrive in a chat thread. A vote comes back even. Someone quietly decides, and the group finds out later.

Every planning tool is good at the parts either side of that moment — listing places, drawing a route — and vague about the moment itself. Wayfold's slice is built around it: the shortlist vote is designed to come back tied, and the screen that follows is the one that has to earn its place.

Scope was fixed before any pixel: six primary screens, three edge states, no all-state menu, no debug inspector, no marketing page. Everything the prototype shows is something a trip owner would actually meet.

The one trip the prototype ships

Solvara long weekend · Fri 8 – Sun 10 May 2026 · Renna Calder (owner) with Mara Ostell and Devan Roche · shortlist of Halra Point, Vessant Quarter and Ombrine Terraces · five stops from the ferry dock to the Cala Verde boardwalk at sunset.

Trip Brief screen: a full-bleed Solvara photograph with the city name over it, and a paper sheet below carrying the trip title, dates, three companion rows and a Set preferences button.
Plate 1Trip Brief. The roster is the state — who is on the trip, and what each of them may edit.

Every place, person and time here is invented. No real business, price, review or opening hour appears anywhere in the build — the fiction is the fixture, and it is identical in the spec, the stories and the tests.

The route

Six stops, and three places the route can leave the line

Each transition has exactly one trigger, decided in advance. Nothing in the flow advances on a timer or a guess, and every trigger below is a control the product actually exposes — which is why the same list drives the end-to-end tests.

  1. Trip Brief

    Trip has a name and dates, both companions appear in the roster → Set preferences

  2. Preference Pass

    Panels 1–4 each hold a committed choice, progress reads 4 / 4 → See destinations

  3. Shortlist Vote

    1st and 2nd picks staged in the rank rail → Submit picks · the result contains a first-place tie

  4. Tie Resolution

    Tie present, so the comparison sheet is entered rather than skipped; evidence read → Confirm Halra Point

  5. Itinerary Map

    Five stops ordered and the closed venue resolved → Confirm plan

    • Detour A — venue closed

      Stop 4's hours do not cover the planned Sunday-afternoon slot, so the row carries an inline Closed for this slot state.

      Returns via “Move to Sat 15:00” → stop marked resolved
    • Detour B — offline map pack

      Saved deliberately before travelling, and re-entered as a fallback when Live Day opens with no connectivity.

      Returns via “Done” → map, or “Use offline” → Live Day
    • Detour C — location permission denied

      Live Day asks for precise location at entry; the modelled OS answer is denied, and a full-screen recovery panel takes over.

      Returns via “Open Settings”, or “Continue without location” → degraded day
  6. Live Day

    End of the critical slice — the itinerary in use, offline, one stop at a time

The folded paper map object rendered for the prototype's offline map-pack state.
The folded map, drawn for the offline pack state and reused as the location-denied illustration.

The happy path is rigged to tie. If the vote came back clean the hardest screen would never be seen, so stop 4 is on the main line, not a branch — the clean-result route exists, but it lives in the test harness.

Design decision · 1

The tie is the product, so it is shown

A group vote lands even far more often than three people expect. The common answer is to break it invisibly — first submitted wins, or alphabetical, or a weighting nobody sees. That produces a decision without a reason, which is exactly what the group was trying to avoid.

Wayfold names the tie in the heading, then earns its way out of it. Three evidence rows — hours, travel, group lean — are open by default, with no disclosure toggle to hide behind. The group split is stated by name: two of three. The winner is proposed; the confirmation is still the user's tap.

“Two picks landed even. Wayfold lined up the facts.”

The ranking that produces the tie has the same rule applied to it. The drag-to-rank gesture is a custom pointer layer, but every rank it can stage is also reachable through named buttons — Move up, Previous, Next destination — so the ranking is not a gesture with an accessibility apology attached, it is two equal paths to the same state.

Shortlist Vote screen: a destination card with a photograph, two rank slots labelled 1st pick and 2nd pick above it, and a sticky Submit picks button.
Plate 2Two rank slots, staged one at a time. Submit stays disabled until both are filled.
Tie Resolution screen: heading Tied for 1st, two columns for Halra Point and Vessant Quarter compared across hours, travel and group lean, and a Confirm Halra Point button.
Plate 3Tie Resolution. The comparison, the named split, and one commit.

The evidence rows are open at rest on purpose. A collapsed row would let the screen claim transparency while still hiding the reason — disclosure you have to ask for is not disclosure.

“Confirm Halra Point” names the destination instead of saying Continue. A generic label would make the commit reversible-looking when it is not.

Design decision · 2

Four questions, asked one at a time

The preference pass could have been one scrolling form. It is four panels instead, each holding a single committed choice — pace, food, culture, budget — with progress reading Step 1 of 4 through Step 4 of 4, and Continue disabled until something is chosen.

Staging it buys two things a form cannot. Each answer arrives with a support line that says what it changes (“this sets the pace of the whole day plan”), which a stacked form flattens into noise. And a partially answered form is ambiguous — skipped, or still thinking? — whereas a panel that will not advance is unambiguous. Completed panels stay reachable: tapping a finished progress segment re-opens it.

Only the fourth panel reveals See destinations. The shortlist is never shown against half a set of preferences.

Preference Pass screen: Step 1 of 4, the question How do you like to move through a place, three choice tiles, and a disabled Continue button.
Plate 4One panel, one commitment, and a support line that says what the answer changes.

This screen failed its first visual review outright — three empty bordered rectangles and three hundred pixels of dead air. The single repair pass was spent here and on five collision defects, not on shadows or spacing nudges.

It then passed a second review it should not have. That review saw panel 1. Panel 2 was rendering its question over its own support line, and panels 3 and 4 opened on an empty tint band, because the head band ordered in the first repair had been verified on one of its four instances. One frame is not a review of a screen that has four states — the reason round 2 was withdrawn, in the colophon.

Design decision · 3

Coming back from no signal and no permission

The three edge states are not error screens bolted on at the end. They are part of the trip: a headland with no reception, a venue shut on the afternoon it was planned for, and an operating system that says no. Each one is treated as a state with a way out, and each way out is a control that already exists on the screen.

Offline is a status, not an alarm

The map pack is offered before it is needed — a control on the Itinerary Map, downloaded with visible progress, ending in a quiet Downloaded chip. The same state is re-entered as a fallback if Live Day opens with no connectivity, so the fallback is a path the user has already seen. Once offline, Live Day carries a small OFFLINE pill and nothing else changes: the time rail, the current stop and the next action all still work.

A refused permission still leaves a working plan

Live Day asks for precise location at entry. When the answer is denied, the recovery panel leads with what location is for — marking the current stop and timing the walk — then offers two real exits: Open Settings, which deep-links out and returns with a live position, and Continue without location, which enters a degraded day where the current stop is chosen by hand. The plan survives the refusal, which is the whole point of saying so in the copy.

A closed venue blocks the commit, not the user

Stop 4's hours do not cover its Sunday-afternoon slot. The row says so inline and offers the specific fix — Move to Sat 15:00 — and Confirm plan stays disabled until it is resolved. The gate is on the plan being coherent, not on the user having read a warning.

Itinerary Map screen: numbered pins on a route with a cobalt line, and a sheet below listing five numbered stops with times, including a closed-for-this-slot row.
Plate 5Numbered pins mirrored by a numbered list. The closed stop carries its own recovery action.
Live Day screen: heading Today in Solvara with an OFFLINE pill, a vertical time rail of five stops, and the current stop promoted to a photographic card.
Plate 6Live Day, offline. The pill states the condition; the day is unchanged.
Location permission denied screen: a folded map illustration with a struck-through location dot, the heading Location is off, an Open Settings button and a Continue without location link.
Plate 7Permission refused. Two exits, and neither of them is a dead end.

All three edge states are public, not QA fixtures. A person on the trip reaches every one of them by using the product, so they are held to the same finish as the happy path.

Nothing here uses a blocking banner. A banner tells you that you are stuck; a status plus a named action tells you what to do next.

The take

The whole slice, walked once, unbroken

Recorded straight from the running build. Every step in the take goes through a control the product exposes by role and accessible name — the same drivers the end-to-end suite uses — so the recording is the prototype behaving, not a storyboard being played back.

Length
24.64 s
Frame
390 × 844 · VP8 / WebM
Captured by
Playwright, against the real Vite build
Covers
Trip Brief → four preference panels → ranking → tie → itinerary and closed-venue fix → location denied → offline pack → Live Day

The take has no cuts and no speed-ups. Pauses were added between taps so the screens can be read — the only thing directed here is the pace.

Coverage

What a user can reach, and what only the harness can

Every state in this project is filed on one of two surfaces before it is built. Public states are the ones a trip owner reaches by using the product. QA-only states — skeletons, validation resets, a three-way tie, the empty map, the clean-vote branch that skips the tie — live in Storybook and the test suite and never appear in the shipped bundle.

That split is why the prototype has no states menu, no help overlay, no debug panel, no inspector, no flow-step list and no reduced-motion toggle. Reduced motion is honoured through the prefers-reduced-motion media query alone, because the trip slice has no genuine product need for a switch. A test asserts all of that at runtime on every rendered surface, not just in the source.

State inventory, counted from the screen × state matrix
Screen Public QA-only
1 · Trip Brief52
2 · Preference Pass61
3 · Shortlist Vote62
4 · Tie Resolution32
5 · Itinerary Map62
6 · Live Day61
42 states inventoried3210

The public column is not a claim, it is walked. A permanent audit harness drives all 32 of those states in the real build, photographs each one and runs six DOM detectors over it — overlap, overflow and clipping, truncation, touch target, dead space, contrast — at 390 × 844, with six of them repeated at the 430 × 932 sanity width. 38 renders, 0 findings, no exclusion list. Its first run was not clean: it raised 61 findings across 22 distinct defects, and those were repaired rather than reclassified.

Where the states are exercised

  • 38 Storybook stories
  • 34 public
  • 4 QA-only
  • 6 story files
  • 27 Playwright tests, all passing
  • 9 surfaces scanned by axe
  • 38 audited renders, 6 detectors each
  • 0 detector findings

32 filled — reachable in the prototype. 10 outlined — harness only.

Storybook stories carry the same ● / ○ marking as the inventory, so a story cannot quietly become a state the product does not have.

Field checks

Accessibility, measured rather than asserted

axe-core was run over all nine rendered surfaces at 390 × 844 with the WCAG 2.0 / 2.1 A and AA rule sets plus best-practice: 0 serious, 0 critical, and no waiver of any kind. Ten moderate findings remain and are named rather than hidden — landmark nesting and heading order on the device-shell surfaces. They are recorded in the report, outside the gate, unfixed.

The contrast repair, and why the brand colours survived it

The first review caught every filled call to action failing AA: the paper label on brand vermilion measured 3.07:1 and on sage 2.29:1, against the 4.5:1 that 16 px semibold button text requires. The cheap fix would have been to darken the brand. Instead two derived fills were added that apply to solid buttons and nothing else, leaving vermilion and sage untouched in every tint, chip, border, status and accent role they already held.

  • Brand vermilion #E65B45 as a filled button — 3.07:1, fails AA
  • Derived fill #BE3E294.68:1, passes
  • Brand sage #8AA78A as a filled button — 2.29:1, fails AA
  • Derived fill #4F6B505.15:1, passes

Contrast on a photograph, read off the pixels

Two lines in the product sit directly on the hero photograph, where a DOM walk has no ground colour to read and axe reports nothing. The audit harness measures them rather than calling them indeterminate: the glyphs are painted transparent, the frame is re-shot, and every pixel of the em band behind each line is scored against the type colour — the run takes the worst of them. Trip clears 6.74:1 straight off the photograph and needs no backing at all; Solvara reaches 3.45:1 through a tuned scrim, against the 3:1 that 34 px display type requires. The same thresholds, on measured pixels, with 0 runs left unmeasured.

An earlier answer to the same problem was withdrawn: opaque slabs were put behind both lines, which satisfied the measurement by destroying the hero. The fix for “we cannot measure this” is to measure it, not to remove the thing that made it hard.

Ranked by keyboard alone

The most gesture-shaped interaction in the product is drag-to-rank, so it is the one held to the strictest proof. The critical flow is walked twice in the suite: once with a pointer, once with no pointer at all — focus moved to a named control, ranking committed with Enter, all the way to Live Day. Destructive actions are protected by a Radix alert dialog, and the ranking result is announced through a live region rather than being left to visual inference.

Brand vermilion and sage appear on this page only as the two failing exhibits above. No colour that sets a word of copy here measures below 4.68:1 against the paper — the case study is held to the rule it describes.

A waiver was available and was deleted. Nothing in the accessibility result is carried by an exception.

Build notes

Real stack, real components, own gesture layer

  • React 18.3.1
  • TypeScript 5.9.3
  • Vite 5.4.21
  • Radix UI Dialog / AlertDialog / Popover / Progress 1.1.x
  • Storybook 10.5.4
  • Playwright 1.61.1
  • axe-core 4.12.1
  • Fraunces Variable display
  • Plus Jakarta Sans UI

Radix carries the modal, popover and progress semantics. The one thing it does not carry — dragging a destination card up into a rank slot — is written by hand on Pointer Events, deliberately, so the gesture and its button-shaped equivalent stay the same commit rather than two code paths that can drift.

apps/wayfold/src/lib/useDragRank.ts excerpt · comments verbatim
/*
 * Custom accessible gesture layer (Pointer Events) for Shortlist Vote's
 * drag-to-rank. This is our own code — not a third-party DnD library —
 * layered over the Radix primitives elsewhere on the screen.
 *
 * The button-based "Move up" / "Previous" / "Next" controls in the screen
 * are the keyboard/AT-operable equivalents, so no interaction depends on
 * a pointer.
 */
const STAGE_THRESHOLD_PX = 72

const onPointerMove = useCallback((e: ReactPointerEvent) => {
  if (!armed.current) return
  const dy = Math.min(0, e.clientY - startY.current) // upward only (<= 0)
  setOffset(dy)
}, [])

const finish = useCallback(
  (e: ReactPointerEvent) => {
    if (!armed.current) return
    armed.current = false
    setDragging(false)
    ;(e.currentTarget as Element).releasePointerCapture?.(e.pointerId)
    const staged = offset <= -STAGE_THRESHOLD_PX
    setOffset(0)
    if (staged) onStage()
  },
  [offset, onStage],
)

On the use of references

Before any screen was drawn, shipped trip-planning and mapping apps were studied at the level of structure: how a shortlist vote stages a ranking, how a tie is grouped and explained, how numbered map pins are mirrored by a numbered list, how a permission rationale is placed before the system prompt, how a download status stays quiet beside a primary action.

Nothing else was taken. No source brand, wordmark, colour, photograph, place copy, price, review, opening hour, map cartography or proprietary business rule appears anywhere in this build. The reference material is held privately, is excluded from this repository and this package, and no reference application is named or screenshotted here — every image on this page is a capture of the Wayfold prototype or an asset commissioned for it.

The gesture threshold is 72 px of upward travel, and horizontal drift is ignored outright. A rank slot should be hard to fill by accident and trivial to fill on purpose.

Storybook, the test suite and the capture rig live outside src/ by construction, so QA scaffolding cannot be imported into the product by mistake.