Offers Flush Layout & Tab Router โ Design โ
Date: 2026-05-07 Scope: apps/admin/src/merchant/tabs/Offers.jsx, apps/admin/src/merchant/offers/OfferForm.jsx, apps/admin/src/merchant/offers/offerSections/SidebarNav.jsx, plus the offer-form-specific styles in apps/admin/src/shared/styles/styles.css. Context: Visual refinement of the Create/Edit Offer experience. Builds on the architecture from 2026-05-07-create-offer-form-redesign-design.md (sectioned form, per-placement sub-pages, preview rail). This spec addresses the surrounding chrome and field treatment, not the form's information architecture.
Goal โ
Replace the current "floating card on a black canvas" rendering of the Create Offer form with a flush layout that runs against the merchant shell on all sides, and add a routed tab strip at the offers area level so View Offers and Create Offer are first-class siblings rather than mode-switches buried inside a state machine.
After this change, the offers area looks and behaves like one cohesive page rather than a sequence of disconnected modes.
Why now โ
The form currently renders inside a card with rounded corners floating in dark space. Combined with the merchant shell's left nav (also a card-like surface) and the page topbar (another bounded surface), the user reads three nested containers before reaching any input. The interior of the form then adds a fourth nested surface โ the section sidebar โ and a fifth โ the per-section content card โ leading to the "bureaucratic" reading the user flagged.
Flattening this in a single coherent pass โ and introducing the tab router that View Offers and Create Offer have always implicitly needed โ prevents the redesign from getting reverted again (commits 6011f447 โ c2a9a324 show this is the third iteration on the offer form's chrome).
Hard constraints (do NOT touch) โ
These zones were marked off-limits by the user and are explicitly out of scope. Any change that visually alters them must stop and confirm.
- Merchant shell left nav โ
apps/admin/src/merchant/MerchantShell.jsxand its associated CSS classes. The Lantern Merchant brand mark, all top-level nav items (Overview, Offers, Venues, Notes, Photos, Address, Profile), and theVIEWING / Back to Admin / Sign outfooter keep their current rendering. - Existing page topbar โ the strip rendered by
PageHeaderinsideOffers.jsxfor each view ("CREATE OFFER / Define a new promotional offer" + top-right Cancel button). Text content, typography, colors, spacing, and the Cancel button styling all stay as-is. We may add new chrome below the topbar; we do not modify the topbar itself.
Architecture โ
View hierarchy after this change โ
MerchantShell (untouched)
โโ Offers tab content (apps/admin/src/merchant/tabs/Offers.jsx)
โโ PageHeader topbar (untouched) โ LOCKED
โโ Routed tab strip [View Offers | Create Offer] โ NEW
โโ Tab content
โโ View Offers โ OffersList / OfferDetail (existing components)
โโ Create Offer โ OfferForm (chrome flushed; details below)The routed tab strip โ
A new horizontal tab strip sits directly beneath the existing PageHeader, full-width of the right pane.
- Two tabs: View Offers and Create Offer
- Active tab is marked by an orange (
--accent-500) 2px underline at the strip's bottom edge, sharing the strip's bottom border so the active tab visually punctures it - Inactive tabs use
--muted-dark-2; active tab uses--text - 12px vertical padding, 24px gap between tabs, 24px left padding to align with the topbar's content edge
Routing model. The tab strip is backed by React Router nested routes (the merchant shell already uses React Router; MerchantShell.jsx mounts <Route path="offers/*" element={<Offers />} />). Inside Offers.jsx we declare nested routes:
/merchant/:merchantId/offers(index) โ View Offers tab active, rendersOffersList/merchant/:merchantId/offers/createโ Create Offer tab active, rendersOfferForm/merchant/:merchantId/offers/:offerIdโ View Offers tab active (deep state), rendersOfferDetail/merchant/:merchantId/offers/:offerId/editโ View Offers tab active (deep state), rendersOfferFormin edit mode
The tab strip stays on View Offers when detail/edit deep states are active, because edits are conceptually a sub-flow of viewing.
The existing view state machine in Offers.jsx ('list' | 'create' | 'detail' | 'edit') is replaced by route-driven rendering: each route renders the appropriate subcomponent directly. selectedOffer state is removed in favor of useParams().offerId + an in-component lookup or a passed offer prop hydrated from the route's loader. Navigation (Cancel, Back, onSaved) uses useNavigate().
Cancel buttons. The top-right Cancel in the existing PageHeader continues to send the user back to the previous tab/view. The redundant footer Cancel inside OfferForm is removed. (Cancel state semantics: from create โ return to list. From edit โ return to detail.)
Flush form layout โ
The Create Offer tab content abandons all card chrome:
- The form root (
.offer-form-v2) drops its rounded card surface, surrounding margin, and box border. It fills the work area edge to edge: flush against the merchant shell on the left, flush with the bottom of the tab strip on top, and flush with the viewport bottom. - The section sidebar (
SidebarNav) drops its card border on three sides. It keeps only a hairline1px solid var(--border-soft)(rgba(255,255,255,0.05)) on its right edge as a divider. No top border, no bottom border, no left border. Background stays transparent so it inherits--bg. - The section content area drops its card surface. Padding becomes
28px 32px 28px 28px; max-width on individual fields is640pxto keep line lengths readable while letting the canvas breathe.
Sidebar interior cleanup โ
The current sidebar has bureaucratic noise that gets removed:
- The
SECTIONSuppercase header at the top is deleted. The section list speaks for itself. - The
โ done ยท โ editing ยท โ empty ยท ! errorlegend at the bottom is deleted. Color and shape carry the meaning (gray dot = empty, orange dot = active, red dot with halo = error, filled gray = done). For the very small accessibility cost, dots get anaria-labeldescribing their state. - Section item rows are reduced to: 6px status dot + label, 12px gap, 14px row gap. No row backgrounds, no left-border accent stripe.
- Active item gets
--textcolor and a slightly larger dot (7px) with a 3pxrgba(245,158,11,0.15)halo. No background fill, no left-border stripe โ color promotion alone marks active. - Error items use the same dot+halo treatment in
--error(#ef4444) and label color shifts to--error. A small count chip (e.g.1) appears right-aligned when sub-pages have errors. - Sub-items (per-placement entries under Placements) indent 18px and drop one tier in font size (12px โ already accounted for in the existing nested behavior).
Content-area cleanup โ
- The per-section content area also drops bureaucratic noise:
- The
SECTIONuppercase eyebrow above each section title is removed. - The section title (
Overview,Schedule, etc.) is promoted to 22px / 600 /letter-spacing: -0.01emโ it now anchors the page on its own. - Helper text under the section title sits at 12.5px /
--muted/ max-width 540px.
- The
- The redundant footer Cancel button inside
OfferFormis removed (top-right Cancel inPageHeaderis the single Cancel).
Field treatment โ
Inputs lose their card-like surface treatment in favor of baseline underlines:
| State | Treatment |
|---|---|
| Default | background: transparent, no border except border-bottom: 1px solid var(--border), padding: 6px 0 8px 0, text color --muted-dark-2 |
| Focused | border-bottom-color: var(--accent-500), text color --text. No box shadow, no surrounding ring. |
| Error | border-bottom-color: var(--error), error message in --error below the field |
| Disabled | border-bottom-color: var(--border-soft), text --muted-dark-2 |
Field labels move above the input, uppercase, 10.5px, --muted, letter-spacing: 0.08em, font-weight: 600. Character counters sit at the same baseline as the label, right-aligned, 10px tabular-numerics in --muted-dark-2. The counter shifts to --accent-500 at 75% and --error at 95%+ of the limit (matches the existing threshold pattern from the predecessor spec).
The Venue selector retains its dropdown behavior; visually it follows the same baseline-underline pattern with a โพ caret right-aligned.
The Description textarea becomes a baseline-underline block: border: none, border-bottom: 1px solid var(--border), padding: 6px 0, height 56โ80px depending on content density. Resize handle hidden.
Component changes โ
| File | Change |
|---|---|
apps/admin/src/merchant/tabs/Offers.jsx | Add tab strip render between PageHeader and the view-switched content. Add hash sync (read on mount + hashchange, write on tab click). Keep existing view state machine. |
apps/admin/src/merchant/offers/OfferForm.jsx | Remove footer Cancel button block (currently inside .offer-form-v2__content). No other JSX changes. |
apps/admin/src/merchant/offers/offerSections/SidebarNav.jsx | Remove the "SECTIONS" header element and the legend element at the bottom. Keep the section list. Adjust section item markup to drop the left-border accent stripe (move to dot-only active styling). |
apps/admin/src/shared/styles/styles.css | Update .offer-form-v2, .offer-form-v2__main, .offer-form-v2__content, .offer-sidebar, .offer-sidebar__item, .offer-sidebar__icon, plus add new .offer-tabs / .offer-tabs__tab styles. Update field/label/counter classes used inside section components to the baseline-underline treatment. |
The per-section components (OverviewSection.jsx, ScheduleSection.jsx, PlacementsSection.jsx, TargetingSection.jsx, ReviewSection.jsx) and the per-placement sub-tabs do not need JSX changes โ only their inputs/labels pick up the new styles by class. Verify by inspection during implementation; if any section uses inline-styled inputs that bypass the shared classes, port them.
Color tokens used โ
All from the existing apps/admin/src/shared/styles/styles.css token set. No new tokens introduced.
| Token | Where |
|---|---|
--bg (#000) | Form root, sidebar background, work area |
--border (rgba(255,255,255,0.08)) | Tab strip bottom border, default field underline |
--border-soft (new alias for rgba(255,255,255,0.05) if not already present; otherwise reuse --border) | Sidebar right divider |
--text (#e6eef8) | Active tab, active sidebar item, focused-field text, content title |
--muted (#9aa6b2) | Field labels, helper text, sidebar default item |
--muted-dark-2 (#52585f) | Inactive tab, sidebar inactive dot, default field text, character counters |
--accent-500 (#f59e0b) | Active tab underline, active sidebar dot+halo, focused field underline, counter at 75%+ |
--error (#ef4444) | Error sidebar dot+halo, error field underline, error counter |
If --border-soft does not already exist in the stylesheet, add it as a new variable (--border-soft: rgba(255,255,255,0.05);) โ that is the only new token. Confirmed during implementation.
Responsive behavior โ
- โฅ1100px: full layout as described
- <1100px (matches existing
.offer-form-gridcollapse threshold): sidebar collapses to a top dropdown above the section content (existing behavior โ preserved). The tab strip stays horizontal; on very narrow viewports it can scroll horizontally rather than wrap, since it only ever has 2 tabs in the current scope.
Accessibility โ
- Tab strip uses
role="tablist"with each tab asrole="tab"andaria-selected. Tab content area getsrole="tabpanel". Pattern matches the existing.sub-tabsusage inOffersList.jsx. - Section sidebar items keep their button semantics. Status dots get
aria-labeldescribing the state ("complete", "active", "empty", "error") so the legend's removal does not lose the signal for screen readers. - Field labels are always visually present (no placeholder-as-label), even in the new baseline-underline treatment.
Out of scope โ
- Anything in the merchant shell (left nav, top brand area, viewing/admin footer)
- The existing
PageHeadertopbar text, color, or button treatment - The right-side preview rail's internal styling (separate spec)
- The information architecture of the form (sections, sub-pages, configs tab) โ defined in the predecessor spec
- The View Offers tab's own internal styling (we wire the route, but do not redesign
OffersList.jsxhere) - Any data shape, API, or normalization changes
Open decisions deferred to implementation โ
- Whether
--border-softalready exists. If yes, reuse; if no, add it. - Whether
OfferDetailneeds to load the offer document itself when arriving via a deep link (currently it receivesofferas a prop because the parent had it in state). Default:OfferDetailacceptsofferIdand loads the document if noofferprop is provided, so deep links work. - Whether the section-item active state needs any affordance beyond color + dot halo (e.g., does it need a left-edge bar after all for scannability with longer section lists). Default: no bar โ color alone. Revisit if usability testing in implementation reveals scanning issues.