Pocock Skills
Prototyping

Prototype

Quickly test design decisions before committing to an implementation. Two branches: logic (TUI) and UI (variant comparison). Always throwaway.

The Primary Decision: What Are You Testing?

Before writing a single line of prototype code, state which question you are answering. Every prototype answers exactly one of two questions:

Logic Question

“Does this logic feel right?”

You are testing whether a state machine, algorithm, or data transformation produces the right behavior. You do not care what it looks like.

Examples:

— Does this reducer handle all order state transitions?

— Does this rate limiter correctly count concurrent requests?

— Does this tree traversal find the right node?

UI Question

“What should this look like?”

You are testing whether a visual design, interaction pattern, or layout communicates clearly. You do not care about the underlying logic.

Examples:

— Should the checkout flow be a wizard or a single page?

— Does this table layout communicate pricing clearly?

— Which error message pattern is clearest to users?

Mixing questions kills prototypes

Prototypes that try to validate both logic and UI at once produce neither a reliable logic test nor a usable design test. If you find yourself styling a state machine, stop and split the prototype.

Logic Branch: State Machine / Reducer Prototype

State the question explicitly

Write the question as a single sentence at the top of the prototype file. This prevents scope creep.

// PROTOTYPE QUESTION:
// Does the order state machine correctly handle a payment failure
// followed by a retry that succeeds?
//
// THROWAWAY: delete or absorb after answer is confirmed.
// DO NOT use this file as the basis for the production implementation.
Pick the language for logic

For logic prototypes, choose the language that minimizes ceremony and maximizes clarity of the logic being tested:

LanguageBest forAvoid when
TypeScriptState machines with complex types, reducersTypes add more code than the logic being tested
PythonData transformations, algorithms, tree structuresYou need to share the prototype with the frontend team
Plain JavaScriptQuick state machine sketches, string processingYou need type safety to catch bugs in the prototype
The prototype language does not need to match the production language. A Python prototype that proves the algorithm works is valid evidence even if the production code will be TypeScript.
Isolate logic in a portable module

The logic being tested must live in a pure function, reducer, or state machine — no side effects, no I/O, no framework dependencies. This makes it instantly testable and runnable anywhere.

  • Reducer pattern: (state, event) => state. The entire state machine is one pure function.
  • State machine pattern: explicit states, explicit transitions, no implicit state.
  • Pure function pattern: (input) => output. No global state, no side effects.
// Reducer pattern — entire order state machine as a pure function:
type OrderState =
  | { status: "pending" }
  | { status: "processing"; paymentIntentId: string }
  | { status: "paid"; paidAt: Date }
  | { status: "failed"; reason: string; retryCount: number };

type OrderEvent =
  | { type: "PAYMENT_STARTED"; paymentIntentId: string }
  | { type: "PAYMENT_SUCCEEDED"; paidAt: Date }
  | { type: "PAYMENT_FAILED"; reason: string }
  | { type: "PAYMENT_RETRIED" };

function orderReducer(state: OrderState, event: OrderEvent): OrderState {
  switch (state.status) {
    case "pending":
      if (event.type === "PAYMENT_STARTED") {
        return { status: "processing", paymentIntentId: event.paymentIntentId };
      }
      break;
    case "processing":
      if (event.type === "PAYMENT_SUCCEEDED") {
        return { status: "paid", paidAt: event.paidAt };
      }
      if (event.type === "PAYMENT_FAILED") {
        return { status: "failed", reason: event.reason, retryCount: 0 };
      }
      break;
    case "failed":
      if (event.type === "PAYMENT_RETRIED" && state.retryCount < 3) {
        return { status: "pending" };
      }
      break;
  }
  return state; // Invalid transition — return unchanged state
}
Build a TUI (Terminal UI) runner

A TUI runner lets you exercise the logic interactively in the terminal. It is not production code — it is a scratchpad for exploring the state machine.

  • One command to run: `npx ts-node prototype.ts` or `python prototype.py`.
  • Shows current state after each event.
  • Accepts event names as command-line arguments for scripted testing.
  • Prints the full state at each step.
// Minimal TUI runner for the order state machine:
let state: OrderState = { status: "pending" };
const events: OrderEvent[] = [
  { type: "PAYMENT_STARTED", paymentIntentId: "pi_123" },
  { type: "PAYMENT_FAILED", reason: "insufficient_funds" },
  { type: "PAYMENT_RETRIED" },
  { type: "PAYMENT_STARTED", paymentIntentId: "pi_456" },
  { type: "PAYMENT_SUCCEEDED", paidAt: new Date() },
];

console.log("Initial state:", JSON.stringify(state, null, 2));
for (const event of events) {
  state = orderReducer(state, event);
  console.log(`After ${event.type}:`, JSON.stringify(state, null, 2));
}
// Run: npx ts-node order-prototype.ts
Logic branch anti-patterns
  • Connecting the prototype to a real database or API. This adds failure modes that have nothing to do with the logic being tested.
  • Adding error handling, logging, or retry logic. You are testing the happy path and the specific edge cases you identified — not building production-grade resilience.
  • Using the prototype file as the starting point for the production implementation. The prototype proved the concept; the production code is written from scratch with proper structure.
  • Spending more than 2 hours on a logic prototype. If you need more than 2 hours, you are building, not prototyping.

UI Branch: Visual Variant Prototype

Sub-shape A: Modify an existing page (preferred)

The preferred approach is to add a ?variant= URL parameter to an existing page. This avoids creating a new route, keeps the prototype co-located with the production code, and makes deletion mechanical.

  • Add a ?variant= query parameter to the existing page URL.
  • Render a different layout when the parameter is present.
  • Share the URL with stakeholders for feedback.
  • Delete the variant condition after a decision is made.
// app/checkout/page.tsx — variant added inline
export default function CheckoutPage({
  searchParams,
}: {
  searchParams: { variant?: string };
}) {
  const variant = searchParams.variant;

  if (variant === "wizard") {
    return <CheckoutWizardVariant />;
  }
  if (variant === "single-page") {
    return <CheckoutSinglePageVariant />;
  }
  if (variant === "sidebar") {
    return <CheckoutSidebarVariant />;
  }

  // Default (production) layout
  return <CheckoutDefault />;
}

// PROTOTYPE NOTE: remove variant routing after decision is made.
// Chosen variant becomes the new default. Others are deleted.
Sub-shape B: New route (last resort)

Create a new route only when modifying an existing page is not feasible — for example, when prototyping a completely new page that does not yet exist, or when the existing page has too much production complexity to safely add a variant condition.

  • Create the new route under /prototype/[name]/ to clearly mark it as non-production.
  • Add a banner or badge to the prototype page marking it as a prototype.
  • Delete the route after the decision is made.
  • Never link to a /prototype/* route from any production page.
// app/prototype/checkout-redesign/page.tsx
// PROTOTYPE ONLY — delete after design decision is made
// Created: 2025-06-01 | Ticket: #1234

export default function CheckoutPrototype() {
  return (
    <div>
      {/* Prototype banner */}
      <div className="bg-yellow-100 border-b border-yellow-200 px-4 py-2 text-sm text-yellow-800">
        PROTOTYPE — not for production use
      </div>
      {/* Prototype content */}
    </div>
  );
}
Build 3+ radically different variants

The point of a UI prototype is to discover what you do not know about the design space. This requires genuine exploration, not minor variations on one approach.

What 'radically different' means

If all three variants use the same layout structure and differ only in color or typography, you have not explored the design space. Radical differences mean: different information hierarchy, different interaction model, different progressive disclosure strategy.
  • Variant A: optimize for first-time users who need guidance.
  • Variant B: optimize for returning users who need speed.
  • Variant C: optimize for mobile users on slow connections.
  • Do not evaluate the variants yourself. Share them and collect feedback.
?variant= URL parameter switcher

Add a floating bottom bar that lets stakeholders switch between variants without editing the URL manually. This dramatically reduces friction in feedback sessions.

// components/variant-switcher.tsx
// PROTOTYPE UTILITY — delete with the prototype
"use client";
import { useRouter, useSearchParams } from "next/navigation";

const variants = [
  { key: "default", label: "Default" },
  { key: "wizard", label: "Wizard" },
  { key: "single-page", label: "Single Page" },
  { key: "sidebar", label: "Sidebar" },
];

export function VariantSwitcher() {
  const router = useRouter();
  const searchParams = useSearchParams();
  const current = searchParams.get("variant") ?? "default";

  return (
    <div className="fixed bottom-4 left-1/2 -translate-x-1/2 bg-white border border-gray-200 rounded-full shadow-lg px-4 py-2 flex gap-2 z-50">
      <span className="text-xs text-gray-500 font-medium self-center mr-1">Variant:</span>
      {variants.map((v) => (
        <button
          key={v.key}
          onClick={() => {
            const params = new URLSearchParams(searchParams.toString());
            if (v.key === "default") {
              params.delete("variant");
            } else {
              params.set("variant", v.key);
            }
            router.push("?" + params.toString());
          }}
          className={`text-xs px-3 py-1 rounded-full font-medium transition-colors ${
            current === v.key
              ? "bg-gray-900 text-white"
              : "text-gray-600 hover:bg-gray-100"
          }`}
        >
          {v.label}
        </button>
      ))}
    </div>
  );
}
UI branch anti-patterns
  • Adding persistence to a UI prototype. The prototype tests layout and interaction, not data management.
  • Polish. Typography tuning, pixel-perfect spacing, custom animations. These belong after the design decision is made.
  • Feature completeness. Every variant should be visually complete for the question being tested, but should not implement adjacent features.
  • Using real user data in prototypes without consent. Use realistic fake data instead.

Universal Prototype Rules

Rules that apply to both branches
RuleLogic branchUI branch
ThrowawayDelete or absorb into production code from scratch after decisionDelete variant condition and unused variants after decision
One command to runnpx ts-node prototype.ts or python prototype.pynpm run dev, then navigate to ?variant=X
No persistenceNo database calls, no file writesNo form submissions, no API calls
Skip polishNo error handling, no logging, no retryNo custom animations, no pixel-perfect spacing
Surface statePrint the full state at each stepShow which variant is active in the floating bar
Delete or absorbIf the prototype works, build the real thing. Do not ship the prototype.If a variant wins, rebuild it cleanly as the default. Do not promote the prototype.

Never ship a prototype

The most common prototype failure is promoting a prototype to production because it “works.” A prototype proves a concept. Production code is built from that proof. These are different activities and different codebases.