Skip to content

The offer builder, driven end to end โ€‹

  • Why this exists: the offer creation and builder flow is the one merchant surface the operator excluded from her redesign, so work there cannot be thrown away by a direction choice. It is also the merchant's core action, the largest surface in the portal, and nobody had exercised it past step one.
  • Status: four fixes, all verified live and pushed. Two suspicions disproven. One item reported and untouched.
  • Date: 2026-08-28.
  • Server: http://localhost:3013, a throwaway Vite portal this session stood up from the worktree on this branch. The operator's own stack on 3001 was never touched.

What was actually wrong? โ€‹

B1. The character limits were enforced on one of three paths. โ€‹

  • PLACEMENT_CHAR_LIMITS gives hero 40 and 80, inline and feed 30 and 60.
  • ContentTab passes them to the override field, so TYPING an override for an inline card is capped at 30.
  • OverviewSection hardcoded 40 and 80, which are hero's numbers, whatever placements were selected.
  • validatePlacementSubpage only checked that a title and description RESOLVE to something non-empty. It never consulted the limits.

So the failure had every indicator agreeing and every one of them wrong: type 40 characters in Overview, select Inline Card, inherit. The sub-page read "Inheriting from Overview". Placements read COMPLETE. Review put a green check on the title. The offer would publish ten characters over what that card renders, and the phone preview was already drawing the title edge to edge the whole time.

  • Fixed in the validator, which reuses the resolveContent it already called and the error status the sidebar and Review already render. No new UI: the Review card picked the message up on its own and reads "Title is 10 characters over the 30 allowed here."
  • The shape is the day's recurring one: a rule that exists, is correctly defined, and is consulted in one place out of three. Same family as the address join living in two services and the placement labels living in three components.

B2. The Overview counter displayed a limit that did not govern its own field. โ€‹

  • It read 40 and 80 regardless of selection, so "40 / 40" sat under a field about to be rejected at 30.
  • Now shows the most restrictive applicable limit, since the Overview content is inherited by every selected placement and the strictest one decides whether publish succeeds. Nothing selected falls back to the loosest any placement allows, which is what it showed before.
  • maxLength follows but does not truncate: selecting inline after writing forty characters shows 40 / 30 in red and keeps all forty. It only stops you adding more.

B3. A placement row nested its checkbox inside a role="button" div. โ€‹

  • axe, before: nested-interactive, impact serious, on all three rows. "Interactive controls must not be nested. Element has focusable descendants."
  • axe, after: zero violations.
  • The row carries two actions, preview and select, and was built as one role="button" div with the checkbox inside it. The preview is a real button now and the checkbox is its sibling.
  • Three things fell out of using the real element: Enter and Space work without the hand-written onKeyDown, the checkbox no longer needs stopPropagation because there is no parent handler to stop, and the button gets a focus ring the div never had. A keyboard user could reach the old row and could not see that they had.

B4. The checkbox and the row had the same accessible name. โ€‹

  • Both read "Hero Rail", which told a screen reader nothing about which did what. The checkbox now reads "Show this offer in Hero Rail".

What was suspected and turned out to be fine? โ€‹

The four set-state-in-effect warnings do NOT produce a reverting field. โ€‹

  • The hypothesis was that a form with an inheritance model and set-state-in-effect warnings would show a field reverting while you type, or an override that silently does not stick.
  • Typed a title and a description, navigated to another step, came back: both held exactly. Round-tripped again through the per-placement pages: still exact.
  • A lint rule firing is not evidence that the thing it warns about happens. Recorded because the fix would have been real code changing real behaviour on a false premise.

The per-placement pages rendering no text inputs is the inheritance model working. โ€‹

  • A probe counting text inputs returned zero and nearly became "the per-placement step is empty". The fields appear when the override is toggled on; until then the page shows what it is inheriting, which is the design.

What is reported and untouched? โ€‹

PhonePreview.jsx:99 calls Date.now() inside a useMemo. โ€‹

  • Flagged react-hooks/purity, with an exhaustive-deps warning on the same line saying its two dependencies are unnecessary. A memo keyed on dependencies that do not affect its value, wrapping a clock call, is a preview whose idea of "now" moves on unrelated re-renders.
  • Not touched: the phone preview is a locked artifact under the design skill. The fix is not obvious and the file has a stated owner.

What is worth saying about the rest of it? โ€‹

The builder deserved its carve-out. โ€‹

  • The Review step is the best surface in the merchant portal: every section with a fraction complete, Audience marked Required and Geofence radius marked Optional, and any card clickable to jump back and edit.
  • The inheritance model reads clearly, the phone preview updates live as you type, and there are zero native select elements.
  • Recorded because the next person deciding what to redesign will read this, and a carve-out that turns out to deserve its carve-out is worth saying out loud.

How was it verified? โ€‹

ClaimHow
B1 blocks publishA unit test on validateAll, two states differing by the title alone. The BROWSER could not prove it: Publish was disabled in both arms because Schedule was empty in both
B1 is visible to the merchantReview renders the message in arm A and not in arm B, with only the title changed
B2 shrinks, and only when it shouldFour live states. Hero-only staying at 40 is the falsifying arm: a helper returning the global minimum would read 30 there
B3axe against the running builder, before and after
Focus ringReal keyboard navigation, since .focus() does not trigger :focus-visible. matchesFocusVisible true, 2px solid amber
Nothing else broke135 merchant tests

Built with VitePress