All articles
EngineeringAIFrontend

The Frontend ADLC: How I Ship Production UI With Agents, Specs and Zero Figma

Bishakh Neogi
Bishakh Neogi

Founding Engineer

September 14, 202618 min read
frontend-adlc-specs-agents-no-figma

The Frontend ADLC: How I Ship Production UI With Agents, Specs and Zero Figma

A field guide from the seat where product intent, backend reality and pixels collide.

The Tuesday that broke my old process

It was a Tuesday. Standup ended at 10:12. By 10:14 I had three things in my inbox, and none of them were a design.

The first was a Slack message from product: "Customers want to filter the billing table by date range and export the result. Can we get it this sprint?" The second was a link to a Figma file that had last been touched eleven weeks ago, showing a table that no longer existed in our codebase. The third was a Notion comment from a backend engineer: "FYI the export endpoint is going to be async now, we will return a job id and you poll it."

Old me would have done the responsible thing. I would have asked for a design. I would have waited two days. I would have gotten a Figma frame that showed the happy path in one viewport at one density with three rows of fake data, and then I would have spent the sprint inventing everything the frame did not show: the empty state, the 43-row overflow, the polling spinner, the failure after 30 seconds, the date range where "from" is after "to", the timezone mismatch between the picker and the server, the screen reader announcement when 812 rows collapse to 4.

The design never contained those. It never did. The design was always a mood board with coordinates, and I was always the person who turned it into a system that does not fall over.

That Tuesday I did not ask for a design. I opened a terminal, and I wrote a spec.

The thesis of this entire post. Shipping has become faster these days, but being a Frontend Engineer I become the pivot for the Product and the Backend Team with no more Figma being adhered to it. The bottleneck moved. It is no longer typing speed, and it is no longer the mockup. It is the quality of the thinking you can hand to a machine.

What follows is the loop I actually run now. I call it the frontend ADLC, the AI Development Life Cycle. It is not SDLC with a chatbot bolted on. It is a different shape, because the expensive step changed. In SDLC the expensive step was implementation. In ADLC implementation is nearly free, and the expensive step is specification, constraint and verification. Everything in this post is a way of spending your time on those three.


The shape of the loop

Here is the whole thing on one page. I generate this diagram from the spec itself and keep it in the PR description, because a reviewer who can see the loop asks better questions than a reviewer who reads 900 lines of diff.

text
flowchart TD
    A[Raw intent from Product] --> B{Brainstorm and grill}
    B -->|edge cases surfaced| C[SPEC.md]
    B -->|unknowns| A

    C --> D[Mermaid contract diagram]
    D --> E[Artifact: shareable, clickable spec]
    E -->|product and backend sign off| F

    F[DESIGN.md: tokens, rhythm, motion, a11y] --> G[Agent definitions + agent memory]
    G --> H[Design references: aura.build, 21st.dev, KokonutUI]
    H --> I[TDD: every spec scenario becomes a failing test]

    I --> J{Orchestrator}
    J -->|component slice 1| K1[Agent: DateRangeField]
    J -->|component slice 2| K2[Agent: BillingTable]
    J -->|component slice 3| K3[Agent: ExportJobToaster]
    J -->|component slice n| Kn[Agent: ...]

    K1 & K2 & K3 & Kn --> L[Pattern choice inside guardrails]
    L --> M[Green unit + integration tests]
    M --> N[Playwright CLI: end to end, real browser]
    N -->|fail| J
    N -->|pass| O[Ship]
    O --> P[Memory write-back: what we learned]
    P --> F

Read the two feedback edges carefully, because they are the point. N -> J means a failing end to end test re-enters the orchestrator, not my brain. P -> F means every ship updates the design memory, so the next feature starts smarter than this one did. A loop without those two edges is just a pipeline, and pipelines rot.

Note. I publish this diagram as an interactive artifact, not a PNG. Product clicks a node and sees the spec section it came from. Backend clicks the export node and sees the exact contract I am coding against. It took me one afternoon to set up and it deleted roughly half of the "wait, I thought it worked like..." conversations from my calendar.


Stage 1: The spec, and the grilling it has to survive

A spec written by one person in one sitting is a wish list. A spec that has been adversarially interrogated is a contract. The difference is entirely in the questions asked before the first line is written.

So I do not start by writing the spec. I start by being cross examined about it. I run a brainstorming pass whose only job is to attack the request until it stops being vague: what happens at the boundary, what happens when the network is slow, what happens when the user is hostile, what happens when the data is ugly, what happens when the backend changes its mind.

Here is a real transcript shape from that Tuesday, compressed:

text
Me:   Feature: filter the billing table by date range, then export the result.

Grill: What is the maximum range? Is "last 18 months" allowed if we only
       retain 12 months of invoices?
Me:    ...retention is 12 months. Range beyond that must clamp and tell the user.

Grill: Timezone. The picker runs in the browser. Does the server filter in
       UTC or in the account's billing timezone?
Me:    ...billing timezone. So the picker must display the account timezone,
       not the device one, or two users see different totals for the same range.

Grill: Export is async now. What does the user see between "clicked" and
       "file downloaded"? Can they navigate away? Can they click twice?
Me:    ...they can navigate away, the job must survive a route change. Second
       click while a job is running is a no-op with a "still working" hint.

Grill: What if the job fails at second 42? What if it succeeds but returns
       zero rows? What if the user's session expires mid-poll?
Me:    ...three distinct states I had not planned for.

Grill: Keyboard only user. How do they set a range without a mouse?
Grill: Screen reader. When the table goes from 812 rows to 4, what is announced?
Grill: 320px viewport. Does the range picker fit, or does it become a sheet?

That exchange took eleven minutes. It surfaced nine states that no Figma frame in the history of my career has ever contained. Nine states is roughly nine bugs I did not ship, and each of those bugs would have been found in QA at the end of the sprint, which is the most expensive place to find anything.

What the spec actually looks like

The output is a SPEC.md that lives next to the feature, in the repo, in the PR. Not in a ticket, not in a doc that nobody opens again. The format matters less than the discipline, but this is mine:

text
# SPEC: Billing history filter and export

## 1. Intent
Let an account owner narrow their invoice history to a date range and
export exactly what they can see, as CSV.

## 2. Actors and permissions
- Account owner: full access.
- Billing viewer: can filter, CANNOT export. Export control is rendered
  disabled with a reason, not hidden. (Hidden controls generate support
  tickets; disabled controls with a reason do not.)

## 3. Data contract
GET  /v2/invoices?from=&to=&tz=
POST /v2/invoices/export  -> 202 { jobId }
GET  /v2/exports/  -> { status: queued|running|done|failed,
                               url?: string, error?: string }

Filtering happens SERVER side, in the account billing timezone.
The client never filters a page it already has. Non negotiable:
client side filtering silently lies once pagination exists.

## 4. States (the real spec)
S1  idle, no range set          -> full history, page 1
S2  range valid, loading        -> skeleton rows, controls stay interactive
S3  range valid, results        -> table + result count + export enabled
S4  range valid, zero results   -> empty state with "clear filter" action
S5  range invalid (from > to)   -> inline error, request NOT sent
S6  range exceeds 12mo retention-> clamp + persistent notice, request sent
                                   with the clamped range
S7  export queued               -> toast with progress, survives navigation
S8  export failed               -> toast with retry, error text from server
S9  export done, zero rows      -> toast says so, no empty file downloaded
S10 session expired mid poll    -> stop polling, route to re-auth, resume
                                   the job on return

## 5. Accessibility contract
- Range picker reachable and operable by keyboard alone. Escape closes
  and restores focus to the trigger.
- Result count lives in an aria-live="polite" region. Changing the filter
  announces "4 invoices, filtered from 812".
- Export job status uses aria-live="assertive" ONLY on terminal states
  (done, failed). Progress does not interrupt.

## 6. Performance budget
- Filter interaction to first painted row: < 400ms p75 on a 4x CPU throttle.
- The table virtualises above 200 rows. Below that, do not.
- No layout shift when skeleton swaps to real rows. Reserve the height.

## 7. Explicit non goals
- No saved filters. No scheduled exports. No PDF. Ask me again next quarter.
Note on section 7. The non goals section is the highest leverage paragraph in the document. Agents are relentlessly helpful, and unbounded helpfulness is scope creep with good intentions. Writing down what you are not building is how you stop an agent from cheerfully adding a scheduling modal nobody asked for.

Notice that section 4 is the bulk of the spec. That is deliberate. A frontend feature is not a screen, it is a state machine that happens to have a screen attached. Once the states are enumerated, every later stage in this loop has something concrete to consume: the tests iterate the states, the design system styles the states, the orchestrator slices work by the states, and Playwright walks the states in a real browser.


Stage 2: The diagram is the handshake

Prose specs get skimmed. Diagrams get argued with, and argument is what I want, as early as possible and from the people who will be angry later.

So the moment the spec stabilises I render its state machine as Mermaid and publish it as an artifact that product and backend can open in a browser without installing anything.

text
stateDiagram-v2
    [*] --> Idle

    Idle --> Validating: user picks range
    Validating --> InvalidRange: from > to
    Validating --> Clamped: range > 12 months
    Validating --> Loading: valid
    Clamped --> Loading: auto, with notice

    InvalidRange --> Validating: user edits range

    Loading --> Results: rows.length > 0
    Loading --> Empty: rows.length == 0
    Loading --> LoadError: 5xx or timeout
    LoadError --> Loading: retry

    Results --> ExportQueued: owner clicks Export
    Empty --> Results: clear filter

    ExportQueued --> ExportRunning: poll 202
    ExportRunning --> ExportDone: status done
    ExportRunning --> ExportFailed: status failed
    ExportRunning --> Reauth: 401 mid poll
    Reauth --> ExportRunning: session restored

    ExportDone --> Results: toast dismissed
    ExportFailed --> ExportQueued: retry

Three things happen when I share that.

Product reads it and says "oh, if the export is empty we should not download a file at all", which is a product decision I would otherwise have made alone at 11pm. Backend reads it and says "the 401 case will actually be a 419 because of our refresh flow", which is a contract correction that costs thirty seconds now and half a day in QA later. And I read it back to myself and notice that LoadError has no path to Idle, which means a user who fails twice is trapped. All three of those are found before a single component exists.

Note. This diagram replaces the Figma handoff, and it replaces it upward, not downward. A mockup tells you what one state looks like. A state chart tells you every state that exists. I would rather have the complete map in grey boxes than one beautiful corner of the territory.

Stage 3: DESIGN.md, and giving your agents a memory

Here is the failure mode that makes people give up on agent built UI: it looks generic. Slightly rounded cards, a blue that is almost your blue, three different shadow values, four different spacing rhythms, and a heading scale that drifts on every page. It is not wrong. It is just nobody's.

That happens for a boring reason. The agent had taste but no constraints, so it reached for the statistical average of the internet. The fix is equally boring: write the constraints down, make them machine readable, and make them the first thing every agent reads.

This is the top of the DESIGN.md I keep at the root of the repo:

text
# DESIGN.md

## 0. Rules of engagement
1. Never introduce a raw hex value. If a token does not exist, propose one
   in the PR description and use the nearest token meanwhile.
2. Spacing comes from the 4px scale only: 4 8 12 16 24 32 48 64 96.
   There is no 18, there is no 30.
3. One accent. #4940FF. Everything else is neutral. If a UI needs a second
   accent to be understandable, the UI is wrong, not the palette.
4. Motion has one job: explain what moved where. 160ms for state,
   240ms for entrance, cubic-bezier(0.22, 1, 0.36, 1). Nothing bounces.
5. Every interactive element has a visible focus ring. No exceptions,
   no `outline: none` without a replacement in the same rule.

## 1. Tokens (source of truth)
--color-cosmos-500: #4940FF;   /* primary            */
--color-cosmos-600: #3A32E0;   /* pressed / hover    */
--color-nl-0:       #FFFFFF;   /* page background    */
--color-nl-50:      #F8FAFC;   /* surface            */
--color-nl-200:     #E3EAF3;   /* border, hairline   */
--color-nl-700:     #6C849D;   /* text muted         */
--color-nl-1300:    #0C1927;   /* text primary       */

## 2. Type scale
display 40/1.1/-0.022em    h2 25/1.25/-0.015em    body 17/1.75/0
h1      35/1.15/-0.022em   h3 18/1.4/-0.01em      small 14/1.5/0

## 3. Density
Table rows are 44px. Controls are 36px. Touch targets are 44px minimum
even when the visual control is 36px: pad the hit area, not the pixels.

## 4. The taste rubric (how to tell if a screen is done)
- Could you remove one element and lose nothing? Remove it.
- Is there more than one font weight doing the same job? Pick one.
- Does the empty state look designed or forgotten?
- At 320px, does anything need a horizontal scroll? That is a bug.
- Is the most important thing on the screen also the most visually loud
  thing on the screen? If not, fix the hierarchy, not the color.

Then I give the agents identities. An agent definition is a small markdown file with a role, a scope and a set of things it is forbidden from doing. Narrow agents produce better work than one general agent, for the same reason a specialist reviewer catches more than a generalist one.

text
---
name: ui-implementer
description: Builds a single component to spec, inside the design system.
tools: Read, Write, Edit, Bash
---

You implement exactly one component per task. You read SPEC.md and
DESIGN.md before writing a line, every time, even if you think you
remember them.

Hard constraints:
- No raw hex, no arbitrary spacing, no new dependency without asking.
- Every state in the spec must be reachable and visually distinct.
- The component is presentational unless the task says otherwise.
  Data fetching lives in the route, not in the leaf.
- If the spec is ambiguous, you STOP and write the ambiguity into
  NOTES.md. You do not guess. A guess that compiles is worse than
  a question that blocks.

You are done when: every scenario test for this component passes, and
`pnpm lint && pnpm typecheck` are clean.

Agent memory: the part most people skip

Constraints stop drift within a session. Memory stops drift across sessions, which is the drift that actually hurts, because it compounds silently over a quarter.

My memory directory is one fact per file, with enough metadata that the right fact surfaces at the right moment. What goes in there is only the non obvious: the decisions, the corrections, the scars. Anything the codebase already states is not memory, it is just duplication waiting to go stale.

text
.agent-memory/
  toast-never-blocks-navigation.md
  tables-virtualise-above-200-rows.md
  date-picker-uses-account-timezone.md
  focus-ring-token-not-outline-none.md
  we-tried-radix-dialog-and-why-we-kept-it.md
text
---
name: date-picker-uses-account-timezone
description: All date inputs render in the account billing timezone, never the device timezone.
metadata:
  type: project
---

Every date range control in billing renders and submits in the account's
billing timezone, not `Intl.DateTimeFormat().resolvedOptions().timeZone`.

**Why:** two users in Berlin and Bangalore filtering "September" against
the same account were seeing different invoice totals, because the client
was sending device local ISO strings and the server was interpreting them
in the account timezone. It took two days to reproduce and it was reported
as "the numbers are wrong", not as a timezone bug.

**How to apply:** pass `tz` explicitly on every invoice query. Render the
timezone label next to the picker so the user can see which clock they are
filtering in. See [[tables-virtualise-above-200-rows]] for the related
billing table constraints.
Note. The Why line is doing the heavy lifting. A rule without a reason gets overruled by the next confident agent, or by the next confident human. A rule with a two day outage attached to it survives. Write the scar tissue down, not just the policy.

Stage 4: Taste has sources, and you should name them

"Make it beautiful" is not an instruction, it is a hope. Beauty is specific, and specificity comes from references. So before any implementation starts I pull concrete reference points and write down what exactly I am taking from each one.

My standing rotation:

  • aura.build for overall composition and confidence: how much air a hero is allowed to have, how a page establishes one clear focal point instead of five competing ones, how restraint reads as expensive.
  • 21st.dev for component level craft: the micro interactions, the hover and focus choreography, the small states that separate a component that works from a component that feels good under the hand.
  • KokonutUI for motion and surface treatment: layered depth without shadow soup, gradients that carry meaning rather than decoration, entrance timings that do not feel like a demo reel.
  • The product's own best screen, which is the reference everyone forgets. If one screen in your app already feels right, that is your strongest reference, because it is already yours.

The critical discipline: references get translated into tokens and rules, never copied as markup. The output of a reference session is a diff to DESIGN.md, not a pasted component.

text
# DESIGN.md, appended after a reference pass

## 5. Reference translations (2026-09-14)

From aura.build, composition:
  Hero-like sections get a single focal element and a 96px vertical rhythm.
  Supporting text never exceeds 62ch. We were at 78ch and it read as dense.
  -> new token: --measure-prose: 62ch

From 21st.dev, interaction:
  Controls telegraph interactivity on hover in under 120ms, and the hover
  state is a SURFACE change, not a color change. Color changes are reserved
  for selection and validity.
  -> rule: hover = bg step (nl-0 -> nl-50). selection = cosmos-500.

From KokonutUI, motion:
  Entrance animations stagger by 40ms per item, cap the stagger at 6 items,
  and respect prefers-reduced-motion by collapsing to a 0ms opacity swap.
  -> util: useStagger(index, { step: 40, cap: 6 })

NOT taken: their heavy glassmorphism. Our surfaces are opaque. Our contrast
ratios are contractual and glass makes them a coin flip.

That last line matters as much as the rest. Writing down what you rejected, and why, is what stops a future agent from "improving" the design back toward the average.


Stage 5: TDD, because a test is a spec that runs

This is the stage where the loop earns its keep, and it is the stage people skip because writing tests before a component exists feels backwards.

It is not backwards. It is the only way to make an agent's output falsifiable. Without tests, "is this done?" is a matter of opinion, and you are back to reviewing 900 lines of diff by eye at 7pm. With tests, "done" is a command that exits zero.

My rule is mechanical: every state in section 4 of the spec becomes at least one failing test before any implementation begins. Ten states, ten tests, minimum. The spec numbering carries straight into the test names so a failure points at a paragraph, not at a vibe.

text
// billing-filter.test.tsx
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { BillingHistory } from "./BillingHistory";

describe("SPEC 4 / billing filter states", () => {
  it("S3: valid range renders rows and enables export for an owner", async () => {
    server.use(invoices.ok(4));
    render();

    await setRange("2026-09-01", "2026-09-30");

    expect(await screen.findAllByRole("row")).toHaveLength(5); // 4 + header
    expect(screen.getByRole("button", { name: /export/i })).toBeEnabled();
    expect(screen.getByRole("status")).toHaveTextContent(
      "4 invoices, filtered from 812",
    );
  });

  it("S5: from > to shows an inline error and never calls the API", async () => {
    const spy = server.spy(invoices.route);
    render();

    await setRange("2026-09-30", "2026-09-01");

    expect(screen.getByText(/end date must be after the start date/i))
      .toBeVisible();
    expect(spy).not.toHaveBeenCalled();
  });

  it("S6: a range beyond 12 month retention clamps and says so", async () => {
    const spy = server.spy(invoices.route);
    render();

    await setRange("2023-01-01", "2026-09-30");

    expect(screen.getByRole("note")).toHaveTextContent(/only 12 months/i);
    expect(spy).toHaveBeenCalledWith(
      expect.objectContaining({ from: "2025-09-14T00:00:00.000Z" }),
    );
  });

  it("S9: an export that returns zero rows does not download a file", async () => {
    server.use(exportJob.done({ rowCount: 0 }));
    render();
    const download = vi.spyOn(window, "open");

    await userEvent.click(screen.getByRole("button", { name: /export/i }));

    await waitFor(() =>
      expect(screen.getByRole("alert")).toHaveTextContent(/nothing to export/i),
    );
    expect(download).not.toHaveBeenCalled();
  });

  it("viewer sees export disabled WITH a reason, not hidden", async () => {
    render();
    const btn = screen.getByRole("button", { name: /export/i });

    expect(btn).toBeDisabled();
    expect(btn).toHaveAccessibleDescription(/owners can export/i);
  });
});
Note. Look at what those assertions are made of: roles, accessible names, live region text. Not class names, not test ids sprinkled like confetti. A test written against the accessibility tree is simultaneously a correctness test and an accessibility test, and it survives a refactor of the markup. A test written against .css-1x9fj2 is a tripwire that punishes you for improving your own code.

These tests are red before anything is built. That redness is the brief. An agent handed a red test suite and a design system has an unambiguous, machine checkable definition of done, and I have a review that starts from "the suite is green, now let me look at the taste" instead of "let me manually hunt for the eight states you forgot".


Stage 6: The orchestrator, or how work gets sliced

When the spec is grilled, the diagram is signed off, the design system is written and the tests are red, planning is over. Now the work has to be cut into pieces that can run in parallel without stepping on each other.

I do that slicing by component boundary, not by layer. Slicing by layer ("one agent does all the styles, one does all the logic") produces merge conflicts and incoherent components. Slicing by component produces independent, reviewable units.

The orchestrator is the thing that holds the task graph, assigns each slice, and refuses to start a slice whose dependencies are not green.

text
// orchestration/billing-filter.plan.ts
export const plan = {
  spec: "specs/billing-filter/SPEC.md",
  design: "DESIGN.md",
  memory: ".agent-memory/",

  tasks: [
    {
      id: "date-range-field",
      agent: "ui-implementer",
      owns: ["components/billing/DateRangeField.tsx"],
      satisfies: ["S1", "S5", "S6"],
      tests: ["DateRangeField.test.tsx"],
      deps: [],
    },
    {
      id: "billing-table",
      agent: "ui-implementer",
      owns: ["components/billing/BillingTable.tsx"],
      satisfies: ["S2", "S3", "S4"],
      tests: ["BillingTable.test.tsx"],
      deps: [],
      notes: "Virtualise above 200 rows. Reserve row height, no CLS.",
    },
    {
      id: "export-job-toaster",
      agent: "ui-implementer",
      owns: ["components/billing/ExportJobToaster.tsx"],
      satisfies: ["S7", "S8", "S9", "S10"],
      tests: ["ExportJobToaster.test.tsx"],
      deps: [],
      notes: "Must survive route change. State lives above the router.",
    },
    {
      id: "billing-history-route",
      agent: "integrator",
      owns: ["app/billing/history/page.tsx"],
      satisfies: ["all"],
      tests: ["billing-filter.test.tsx"],
      deps: ["date-range-field", "billing-table", "export-job-toaster"],
    },
    {
      id: "e2e",
      agent: "e2e-author",
      owns: ["e2e/billing-filter.spec.ts"],
      deps: ["billing-history-route"],
    },
  ],

  // The gate every task must pass before it is considered done.
  gate: "pnpm lint && pnpm typecheck && pnpm test --run",
} as const;

Two properties of that plan do all the work.

owns is exclusive. Exactly one agent may write a given file. This single rule eliminates the entire category of parallel agent failure where two workers helpfully edit the same file into nonsense. If two tasks want the same file, that is a signal the boundary is wrong, and I redraw it before starting.

satisfies maps to spec states. When the suite is green I can mechanically verify that every state in the spec is claimed by some task. A state nobody claims is a state nobody built, and that check takes one second instead of one sprint.


Stage 7: Patterns are the agent's call, inside my guardrails

This is the part of my process that surprises people the most, so let me be precise about it.

I do not dictate the design pattern. I do not open the task by saying "build this as a compound component". Instead I state the constraints the component must satisfy, list the patterns that are legitimate in this codebase, and hand the choice to the agent along with an obligation to justify it in writing.

The exact wording I use in the task:

text
Pattern selection is YOUR call, and you must defend it.

Legitimate choices in this codebase:

  - Compound components: when a component has several parts that are
    always used together, and the consumer needs to control layout
    and ordering. State is shared implicitly through context.
    Cost: a context read on every part, harder to tree shake.

  - Composition / slots: when the parts are independent and a consumer
    might use one without the others, or might swap one for their own.
    Cost: more props on the parent, more explicit wiring.

  - Pub-sub / external store: when state must outlive the component
    tree, cross a router boundary, or be written by something that is
    not React. Cost: a second source of truth you must keep honest,
    and a subscription you must remember to tear down.

Guardrails, non negotiable regardless of pattern:
  1. No prop drilling deeper than two levels.
  2. The public API is typed, and invalid combinations do not compile.
  3. No context provider that re-renders the whole subtree on every keystroke.
  4. Server components stay server components. Do not add "use client"
     to a file just to make an import resolve.
  5. Write your choice and its trade-off in a `## Pattern` section at the
     top of the file. One paragraph. If you cannot justify it in one
     paragraph, you picked wrong.

Here is what came back for the date range field. Compound, and the reasoning held up:

text
/**
 * ## Pattern: compound components
 *
 * The trigger, the calendar, the presets and the timezone label are never
 * used apart, and every one of them needs the same `{from, to, tz}` state
 * plus the clamp rules from SPEC 4 / S6. Passing that to four siblings
 * would be four identical prop bundles that drift the moment someone
 * adds a field. Context keeps the state in one place and lets the
 * consumer reorder or omit parts without us shipping a `layout` prop.
 * Trade-off accepted: each part reads context, so they cannot be used
 * outside . That is intentional, and it is enforced by
 * throwing from the hook rather than silently returning undefined.
 */

const RangeCtx = createContext(null);

function useRange(part: string) {
  const ctx = useContext(RangeCtx);
  if (!ctx) {
    throw new Error(` must be used inside `);
  }
  return ctx;
}

export function DateRangeField({ value, onChange, tz, children }: Props) {
  const clamped = useMemo(() => clampToRetention(value, RETENTION_MONTHS), [value]);
  const ctx = useMemo(
    () => ({ value: clamped.value, wasClamped: clamped.changed, onChange, tz }),
    [clamped, onChange, tz],
  );
  return {children};
}

DateRangeField.Trigger  = function Trigger() { /* ... */ };
DateRangeField.Calendar = function Calendar() { /* ... */ };
DateRangeField.Presets  = function Presets() { /* ... */ };
DateRangeField.Notice   = function Notice() {
  const { wasClamped } = useRange("Notice");
  if (!wasClamped) return null;
  return We keep only 12 months of invoices, so we adjusted your range.;
};

And the export toaster went the other way, correctly, because its state has to outlive the tree:

text
/**
 * ## Pattern: pub-sub via an external store
 *
 * SPEC 4 / S7 says an export job survives a route change, and S10 says it
 * survives a re-auth round trip. React state cannot promise either: the
 * route unmounts the tree and takes the poller with it. So the job lives
 * in a module scoped store that React only SUBSCRIBES to. The store owns
 * the poll timer and the lifecycle; the component owns nothing but pixels.
 * Trade-off accepted: a source of truth outside React, so the teardown is
 * manual and is covered by an explicit test.
 */

type Job = { id: string; status: "queued" | "running" | "done" | "failed"; url?: string };

const listeners = new Set<() => void>();
let job: Job | null = null;
let timer: ReturnType | null = null;

function emit() { for (const l of listeners) l(); }

export const exportStore = {
  subscribe(listener: () => void) {
    listeners.add(listener);
    return () => { listeners.delete(listener); };
  },
  getSnapshot() { return job; },

  start(id: string) {
    if (job && job.status === "running") return;       // S7: second click is a no-op
    job = { id, status: "queued" };
    emit();
    timer = setInterval(async () => {
      const next = await pollExport(id);
      job = next;
      emit();
      if (next.status === "done" || next.status === "failed") exportStore.stop();
    }, 1500);
  },

  stop() {
    if (timer) clearInterval(timer);
    timer = null;
  },
};

// The component is now trivial, and trivial is the goal.
export function ExportJobToaster() {
  const job = useSyncExternalStore(exportStore.subscribe, exportStore.getSnapshot);
  if (!job) return null;
  return ;
}
Note on why I delegate this. When I dictate the pattern, I get my 2024 instincts applied to a 2026 problem, and nobody in the review argues with me because I am the Staff engineer. When I state constraints and ask for a defended choice, I get a written trade-off I can actually disagree with. The second one produces better code and, more importantly, a codebase where every non obvious decision has its reasoning sitting three lines above it.

Stage 8: Multi-agent execution

Only now does parallel execution start, and the ordering is the whole trick. Parallelism applied to a vague spec is just a faster way to produce garbage in three files at once. Parallelism applied to a grilled spec, a written design system, red tests and exclusive file ownership is genuinely multiplicative.

What each agent receives is identical in shape:

  1. The spec, with the specific state ids it is responsible for.
  2. DESIGN.md, including the reference translations.
  3. The relevant memory files.
  4. Its red tests.
  5. Its exclusive file list.
  6. The pattern menu and the guardrails.
  7. The gate command that defines done.

What I do while they run is not watch them. I review the integration seams: the props crossing boundaries, the shared types, the places where two components have to agree about something. That is where multi-agent work actually fails, and it is the one thing no individual agent can see.

text
# The seam review, roughly
$ git diff --stat
 components/billing/DateRangeField.tsx     | 184 ++++++++
 components/billing/BillingTable.tsx       | 231 +++++++++++
 components/billing/ExportJobToaster.tsx   |  96 +++++
 lib/billing/types.ts                      |  28 ++
 app/billing/history/page.tsx              |  74 ++++

# The only file I read line by line first:
$ cat lib/billing/types.ts
# Because if the shared types are right, three independent components
# that never spoke to each other still compose. If the shared types are
# wrong, everything above it is confidently wrong in the same direction.
Note. Define the shared types yourself, before the fan out. It is maybe fifteen lines of work and it is the difference between three components that snap together and three components that each invented their own shape for an invoice.

Stage 9: Playwright CLI, the gate that does not care about your opinion

Unit tests prove the pieces behave. They do not prove the product works. A component can pass every assertion in jsdom and still be unusable, because jsdom has no layout, no real focus management, no scrolling, no actual browser event ordering and no network timing.

So the last gate is the real browser, driven from the CLI, walking the state machine from the diagram end to end.

text
// e2e/billing-filter.spec.ts
import { test, expect } from "@playwright/test";

test.describe("billing filter and export, SPEC 4 end to end", () => {
  test.beforeEach(async ({ page }) => {
    await page.goto("/billing/history");
  });

  test("S3 -> S7 -> S8: filter, export, survive a route change, then fail gracefully", async ({ page }) => {
    // S3: filter down to a range with results
    await page.getByRole("button", { name: /date range/i }).click();
    await page.getByRole("button", { name: "September 1, 2026" }).click();
    await page.getByRole("button", { name: "September 30, 2026" }).click();
    await expect(page.getByRole("status")).toHaveText("4 invoices, filtered from 812");

    // S7: start the export, then navigate away on purpose
    await page.getByRole("button", { name: /export/i }).click();
    await expect(page.getByRole("alert")).toContainText(/preparing your export/i);
    await page.getByRole("link", { name: /settings/i }).click();
    await expect(page.getByRole("alert")).toBeVisible(); // the toast survived

    // S8: the job fails, the user gets a retry, not a dead end
    await page.route("**/v2/exports/*", (r) =>
      r.fulfill({ status: 200, json: { status: "failed", error: "Export service unavailable" } }),
    );
    await expect(page.getByRole("alert")).toContainText("Export service unavailable");
    await expect(page.getByRole("button", { name: /try again/i })).toBeEnabled();
  });

  test("keyboard only: a user can filter without ever touching a mouse", async ({ page }) => {
    await page.keyboard.press("Tab");
    await page.keyboard.press("Tab");
    await expect(page.getByRole("button", { name: /date range/i })).toBeFocused();

    await page.keyboard.press("Enter");
    await expect(page.getByRole("dialog")).toBeVisible();
    await page.keyboard.press("Escape");

    // SPEC 5: focus returns to the trigger, it does not fall to 
    await expect(page.getByRole("button", { name: /date range/i })).toBeFocused();
  });

  test("no layout shift when skeletons become rows", async ({ page }) => {
    const cls = await page.evaluate(() =>
      new Promise((resolve) => {
        let total = 0;
        new PerformanceObserver((list) => {
          for (const e of list.getEntries()) {
            if (!(e as LayoutShift).hadRecentInput) total += (e as LayoutShift).value;
          }
        }).observe({ type: "layout-shift", buffered: true });
        setTimeout(() => resolve(total), 3000);
      }),
    );
    expect(cls).toBeLessThan(0.1); // SPEC 6: reserve the height
  });
});

And the CLI surface I actually live in:

text
# Write the first draft of a flow by recording it, then harden it by hand.
$ pnpm exec playwright codegen http://localhost:3000/billing/history

# Run one spec headed while you are debugging a flake.
$ pnpm exec playwright test e2e/billing-filter.spec.ts --headed --project=chromium

# The full gate: three engines, in parallel, in CI.
$ pnpm exec playwright test --reporter=line

# When something fails in CI and passes locally, this is the answer.
$ pnpm exec playwright show-trace trace.zip

# Catch the class of bug where a component only breaks on a slow phone.
$ pnpm exec playwright test --project="Mobile Safari" --project="Mobile Chrome"
Note on traces. Turn on trace: "on-first-retry" and never argue about a flaky test again. The trace gives you the DOM, the network, the console and a filmstrip at the exact failing millisecond. It converts "it works on my machine" from an argument into a file you can open.

The rule I hold without exception: a failing end to end test goes back into the orchestrator, not into my editor. If I hand fix it, the fix lives in my head and the loop learns nothing. If it goes back through the loop, the fix arrives with a test that pins it and, when it was a systemic mistake, a new line in DESIGN.md or a new memory file so it never recurs.


What actually changed

Let me be honest about the shape of the win, because it is not the one people expect.

text
Phase                             Before                Now
--------------------------------  --------------------  ----------------------------
Waiting for design                2 to 4 days           0
Spec and edge cases               Discovered during QA  ~2 hours, up front
Implementation                    4 to 6 days           Hours, parallel
Bugs found in QA                  8 to 15 per feature   1 to 3, mostly product level
My time in an editor              ~70%                  ~20%
My time specifying and verifying  ~30%                  ~80%

The last two rows are the real story. I did not become a faster typist. I stopped being a typist. The work moved up the stack into deciding what is true, what is out of bounds and what counts as correct, which is, if we are honest, what senior frontend work always actually was. The typing was just the part that took the longest.

Where this bites, because it does

  • A weak spec fans out beautifully into garbage. Parallelism multiplies whatever you gave it, including the mistakes. If you feel tempted to skip the grilling because the feature is small, that is exactly the feature that will eat your Friday.
  • Design drift is silent. Nothing fails when an agent introduces a fourth shadow value. I now lint for it: a CI rule that rejects raw hex and off scale spacing in components/. Taste needs a linter, not a hope.
  • Tests can be written to pass rather than to verify. If the same agent writes the test and the implementation in one pass, you sometimes get a tautology. Separate the roles, and write the test first, from the spec, before the implementation exists.
  • Review fatigue is real. Four agents produce more diff than you can read carefully at the same depth. Read the seams and the public APIs closely, and trust the gate for the interiors. If you cannot trust the gate, fix the gate, not your reading speed.
  • Memory rots. A memory file that names a component which no longer exists is worse than no memory. I prune the directory every time I do a significant refactor, the same way I would update a README.

The pivot

Six months into running this loop, a product manager asked me for a design review. Not a code review. A design review. She wanted to walk through the states before we built them, because she had learned that the state chart, not the mockup, was where her decisions actually got made.

That is the moment the role changed, and it is worth naming plainly. I am not downstream of design anymore, and I am not downstream of the backend contract either. I sit in the middle, holding the only artifact that contains both: a spec that says what is true, a design system that says what is allowed, and a test suite that says what is done. Product reads it. Backend reads it. Both of them correct it before a line is written, which is the cheapest possible place for them to be right.

Shipping got faster, yes. But the thing that actually changed is that the frontend engineer became the pivot, the place where product intent and backend reality are forced to agree in writing. No Figma file ever did that job. It was never built to.

Start here if you take one thing from this post. Pick the next feature on your board, whatever it is. Before you open your editor, write down every state it can be in. Not the screens, the states. If your list is shorter than eight items, you are not finished thinking. Everything else in this loop, the diagram, the design system, the agents, the tests, the orchestrator, the browser gate, is scaffolding built around that one list. The list is the work.

Work with us

Have a project in mind? Let's talk.

Pilots, platforms, or roadmaps — tell us what you're building and we'll get back within one business day.

Newsletter

Get our latest writing in your inbox.

Agentic engineering, AI platforms, and what we learn shipping them — no spam, unsubscribe anytime.