# DK Bazar — project context

Working notes for anyone (human or agent) picking this up. Everything here was
established by inspecting the design sources directly, not assumed.

## What this is

A B2B China→Bangladesh sourcing marketplace. Not a plain storefront: it has
tiered bulk pricing, per-supplier shipping methods, RFQ, a "ship for me" service
and a landed-cost calculator.

## The two sources of truth

| Source | What it is | Use it for |
|---|---|---|
| `https://dkbazar.vercel.app/` | A previous partial build, live | Real assets, exact Tailwind classes, computed CSS |
| Figma `hvMlVuumQTIvfqXEILfCnN` | The full design, 8 pages | Layout, content, anything the live site lacks |

**They disagree in places.** Where they do, Figma wins unless told otherwise —
the live site is behind. Known divergences:

- Live site's Popular Categories has only 5 categories and no carousel; Figma has
  10 across 2 pages with autoplay and a draggable scrollbar.
- Live New Collection cards show a 5-star rating row; Figma shows
  `Listed N days ago` instead. Figma was followed.
- Live site hardcodes `font-family: Arial` in globals.css (a `create-next-app`
  leftover) which silently overrides the loaded font. Figma specifies
  **Inter Tight**. Fixed here; the live site still has the bug.

### Pulling from Figma

The **render/export** endpoint (`/v1/images/...`) rate-limits hard — it returned
429 for hours at a stretch. Two things that kept working:

- `/v1/files/{key}?depth=N` — the node tree (geometry, colors, classes)
- `/v1/files/{key}/images` — **image fills**: maps every `imageRef` to a
  downloadable source URL. This is how the category icons were obtained when
  export was blocked. Find a node's `fills[].imageRef` in the tree, then resolve
  it through this map.

Most text in the Figma file is **flattened to vector outlines**, so text nodes
mostly do not exist. Read copy from rendered images, and sample colors from the
pixels rather than expecting style data. This is **not** uniform — the Low MOQ
section has fully live text nodes with real `style` blocks, so always check the
tree before falling back to reading pixels.

The 429s are worth diagnosing rather than retrying blindly. Check the response
headers: a **starter**-tier token returns `x-figma-rate-limit-type: low` with a
`retry-after` of ~48 hours, which no amount of polling will clear. A token on a
paid seat lifts it immediately. `curl -I` tells you which case you are in.

**The quotas are per-endpoint, and that is the way out.** When
`/v1/files/{key}/nodes?ids=…` is locked, `/v1/files/{key}?ids=…` may still
answer 200 — it takes the same node filter and returns the *full-depth* subtree,
which `nodes` only does with `depth`. Add `&geometry=paths` for vector path
data. `/v1/images` (render) is a third, separate quota. So on a 429, try the
other two endpoints before concluding you are blocked. Signed S3 URLs from
`/images` (image fills) keep working for ~2 weeks regardless — cache that JSON
early and pull artwork from it long after the API locks.

## Design tokens (verified, in `globals.css`)

| Token | Value | Notes |
|---|---|---|
| `--brand` | `#0652e0` | The live site's real accent. Figma's header swatch reads `#0028ec`; `#0652e0` is what ships. |
| `--ink` | `#010e23` | Dark navy pills/CTAs |
| `--accent-teal` | `#0baabe` | "N days ago" highlight (sampled `#08adc5`) |
| `--danger` | `#ff4c4c` | Wishlist heart, discount badges |
| `--surface-alt` | `#ebe8e7` | Top utility bar |
| `--background` | `#fbfafb` | Page background — **not** white. Taken from the live build's outermost wrapper (`min-h-screen bg-[#fbfafb]`); white cards read as raised against it. Not yet confirmed against Figma — see below. |
| Font | Inter Tight | via `next/font`, wired through `--font-sans` |

Page column is **1200px**, `px-4` below `lg` and `lg:px-0` — matching the live
site's container exactly. Light mode only; dark mode was deliberately removed
because no dark design exists.

## Built so far (home page)

Order in `src/app/page.tsx`: Hero → Popular Categories → New Collection →
Low MOQ Products → Explore Our Global Product → Promo banner grid → In Demand
Top Picks → In Demand Live Now → You May Like To Visit.

- **Header** (`components/layout/Header.tsx`) — top utility bar + main nav.
  Bar is 64px, `#ebe8e7`, **30px rounded bottom corners**, 1px `#d8d8d8`
  dividers, 13px gaps. The language pill is `#1a1a1a` in Figma but with
  **LINEAR_DODGE** blend over the bar, which resolves to pure white — don't
  "correct" it to dark. Icon count badges always render, including `0`.
- **Hero** (`components/home/HeroBanner.tsx`, data in `lib/hero-slides.ts`) —
  3-slide crossfade. Each slide has its **own** prop set (`BLUE ICON`,
  `CYAN ICON`, `PERPLE ICON` — note the typo'd folder, files inside are
  `purple-*`) and its **own** brand wordmark. Autoplay 5s, transition 1.3s.
  Geometry is authored in a 1200×554 stage that scales to the container.
  **There is no mouse parallax** — verified by moving the cursor to opposite
  corners and reading identical transforms. What looks like parallax is the
  slide entrance: big springs fly in from their corner at `scale(0.76)`,
  characters/small springs lift from `translateY(14px) scale(0.94)`.
  Character cut-outs are drawn *larger* than their slot and bleed past it.
- **Popular Categories** (`CategoryRail.tsx`, data in `lib/categories.ts`) —
  10 categories, 5 per page, native scroll-snap container (so trackpad/touch
  work), 4s autoplay that wraps, pause on hover, draggable custom scrollbar.
  Icons float 34px above their tile, so the scroller uses `-mt-[48px] pt-[48px]`
  to avoid `overflow-x-auto` clipping them vertically.
- **New Collection** (`NewCollection.tsx`, data in `lib/new-collection.ts`) —
  5-col grid, 2 rows of the same 5 products (as designed). Tile
  `h-230 rounded-20 bg-[#eef7fc]`.
- **Low MOQ Products** (`LowMoqSection.tsx` + `OfferBannerSlider.tsx`, data in
  `lib/low-moq.ts`, assets in `public/low-moq/`) — a 469px glass card beside a
  704px bordered product rail, 20px gap. The card's Figma fill is black at 96%
  in **LINEAR_DODGE**, i.e. white again (same trick as the header pill). Rail
  border is `#00aaff` at 20%, tiles `#71dbff` at 10%.
  The offer banner is the **3-variant slider** — navy / cyan / yellow, 4.2s
  crossfade with a slow zoom and a glint. Product cut-outs are **cropped, not
  contained**: Figma uses `scaleMode: STRETCH` with an `imageTransform` matrix,
  reproduced as `object-cover` plus a per-product `objectPosition`.

- **Explore Our Global Product** (`ExploreGlobalProducts.tsx` +
  `GlobalProductCard.tsx`, data in `lib/global-products.ts`) — globe mark, then
  a divider, region pills and a 5-col grid. The globe and the pills/divider are
  indented 73px from the grid's left edge, because in Figma they align to the
  *title*, not to the section. Reuses the **same five products** as New
  Collection (artwork shared from `/new-collection`) but swaps the "listed N
  days ago" line for a 5-star rating, tile `rounded-16 bg-[#71dbff]/10`, and a
  `51% Off` gradient badge on the first card. The unsaved heart is bare
  `#ebe8e7` with no circle; only the saved one gets the `#ebe8e7` disc.
  **The region pills are presentation only** — products carry no region yet, so
  selecting one re-runs the grid's fade but does not filter.

- **Promo banner grid** (`PromoBannerGrid.tsx`, data in `lib/promo-banners.ts`,
  assets in `public/promo/`) — 5 tiles, no heading. Grid is `798 + 18 + 380`
  across with 18px gutters everywhere: hero `798x380` over `389x149 + 391x149`
  on the left, `380x264` over `380x265` on the right.
  ⚠️ **The tiles are interim.** The Figma quota ran out before the per-node
  exports, so each tile is a slice of the scale-1 render of node `1:2617`, with
  the copy **baked into the artwork** — not selectable, not translatable, and
  only 1x so it softens on retina. The grid geometry is correct and was measured
  off that render (the sums reconcile exactly), so replacing a tile means
  swapping the image and lifting its text into markup; the layout stays put.

- **In Demand Top Picks** (`TopPicksSection.tsx` + `TopPickCard.tsx`, data in
  `lib/top-picks.ts`) — heading inset 11px, grid 64px below it. Same five
  products again, with a fractional star rating (4.2 → a partial 5th star).
  ⚠️ The design also shows a **review count** and a **struck-through was-price**
  on each card; both were illegible in the only render available and are left
  unset rather than invented. `compareAtPrice` / `reviewCount` are in the type
  and render as soon as they hold a value. The rosette mark is hand-drawn.
- **In Demand Live Now** (`LiveNowSection.tsx`, data in `lib/live-now.ts`,
  assets in `public/live-now/`) — 6 portrait cards, `164x360`, featured one
  `265x435` and bottom-aligned so it rises above the row, 22px gaps. Scrim is
  the design's own gradient: transparent to 76.5%, then `#032d7a`. Each photo is
  an oversized image masked into its card, so offsets are per-card. The featured
  card's arrow is `#141b34`, the rest `#0652e0` with a white ring. Play mark is
  hand-drawn — its node is a solid fill plus a tiled LINEAR_DODGE grain with no
  path geometry.
- **You May Like To Visit** (`VisitLanes.tsx`, data in `lib/visit-lanes.ts`) —
  three labelled rows on a 32px rhythm: trending (3 cards, row 845.6 wide),
  category (4 cards, 1108, ends with the round arrow), business model (3 cards,
  968). Cards are 78px tall. Row widths and y-positions are from Figma; the
  per-card widths, type sizes and icons were inferred from a 0.4-scale render,
  so they are close rather than exact. The supplier counts and the "41,208
  online" badge were read off that render — **verify the digits**.

- **Footer** (`components/layout/Footer.tsx`, data in `lib/footer.ts`, assets in
  `public/footer/`) — wired back into `layout.tsx` after previously being
  deleted. Three link columns (with the dark `Request for Quote` pill inside
  *Our Expertise*), newsletter, brand + support card, WhatsApp / Get a Free
  Quote, socials, payment grid, then a sky panel with a `DK BAZAR` watermark
  (live text, not an image), the container ship, and the bottom bar.
  The footer is a **server component**; only `NewsletterForm.tsx` is a client
  component, which also keeps the copyright year off the hydration path.
  Bottom bar carries the **Developed by → meniworld.com** credit.
  The **sky scene is not a gradient** — an earlier pass guessed one and it was
  wrong in a way no amount of colour-tweaking would fix. In Figma it is ten
  solid ellipses, every one under a 400px `LAYER_BLUR` (Ellipse 16 uses 165),
  stacked in a group called *gradient color* held at **77% opacity**, with a
  separate `Ellipse 15` outside that group at full strength. The warm left edge
  is a **yellow `#eefc72` blob**, not a beige gradient stop; the blue is
  `#0652e0` and `#00bbff`. That stack is baked to `public/footer/sky.png` (720
  x 599, ~92KB) rather than shipped as ten live CSS blurs — it is pure blur, so
  it upscales invisibly. `fig/bake_sky.py` in the scratchpad regenerates it.
  On top sit two real **cloud photographs** (`cloud 1`, `cloud 02` in Figma),
  three white **wave** layers, the ship and two buoys. The near wave paints
  *over* the ship — that is the design's own paint order.

  Everything in the scene is authored in the design's **1440 x 1198** footer box
  and positioned from the **bottom**, because the built footer is ~1060px tall
  (the columns wrap differently than Figma's) and only bottom-anchoring keeps
  the horizon, ship and waves aligned. The wave edge was traced off the Figma
  render and is a full-width SVG with `preserveAspectRatio="none"`, so it
  stretches horizontally without distorting.

  The `DK BAZAR` watermark is the **brand wordmark, not type** — Figma has it
  outlined in a condensed squarish face no loaded font provides. Its glyph paths
  are lifted from `/logo/dk-bazar-logo.svg` (`WORDMARK_PATHS`); that box is
  85.57 x 20.61, a 4.15 ratio, which is exactly the 1075 x 259 drawn here. It
  renders at **30% opacity in HARD_LIGHT**, which is why it reads as a tinted
  emboss rather than flat white.

  The **payment block is 6 x 4 = 24 tiles of 93 x 34 on a 5.4px gutter, 585px
  across** — deliberately narrower than the 1200 container. `paymentMethods` is
  in the design's exact reading order because the grid lays out on that order,
  and each entry carries its own `w`/`h` box from Figma. 23 marks were recovered
  from the image-fill map. Only **Nagad** (row 2, col 1) is missing — it is
  vector paths, not an image fill, so there is nothing to pull; its tile renders
  empty so the other 23 keep their designed slots. Drop in a `nagad.png` and set
  its `image` to finish it.
  **Blueprint guide lines**, straight from Figma: a top hairline 50px down
  starting 27px in, a left rail at x=61 that runs the **full footer height**
  (it just vanishes into the sky), a node dot where they cross, and a column
  split 555px into the container. All `#29b6f6` at ~15% — nearly invisible, and
  easy to miss when comparing. There is no right-hand rail.
  The newsletter column is `1.4fr` against the three `1fr` link columns —
  narrower and the heading wraps to three lines instead of the design's two.

- **Categories mega-menu** (`components/layout/CategoryMegaMenu.tsx`, data in
  `lib/category-menu.ts`, art in `public/category-menu/`) — opens off the
  header's `Categories` pill. The panel spans the **same 1200px container as
  the header** (its left edge lines up with the logo, its right with the
  avatar) and splits **310 rail / 520 detail / 308 promo** on 16px gaps.
  The **panel itself is light grey `#f4f5f7`** and only the middle detail
  column is a white card sitting on it — the rail and promo columns read as
  grey. Getting that wrong (all-white) was the single biggest mismatch on the
  first pass. Preview tiles are **square** (~97px) on `#e9f2fb`, and the promo
  card is **150px tall** — at the 104px first guessed, the whole panel came out
  ~80px short of the design.
  The detail column stacks *Garments + Footwear* on the left and leaves
  *Fabric & Trims* alone on the right — that split is **explicit**, because CSS
  multi-column auto-flow puts the break in the wrong place.
  Built from a screenshot of the design while the Figma API was rate-limited,
  so structure and copy are solid but **exact px/colour values are estimates**
  pending a node-tree pull. The six garment tiles and the Guangzhou expo photo
  are real assets, recovered from the image-fill map by contact sheet.
  The panel is **click-to-open only** — the pill toggles it, and it closes on a
  second click, Escape, an outside click, or following a link. Moving the mouse
  away leaves it open. An earlier pass also opened it on hover and closed it on
  `onMouseLeave`; that was removed on request, and it is worth not
  reintroducing — hover-open fights the click that follows it (see the closure
  note under Gotchas). Hovering a row in the *rail* still switches the active
  category, which is a different thing and stays.

Shared: `SectionHeading.tsx` (leaf mark + blurred glow + title/subtitle + CTA).
`LeafMark` is exported from it and reused by the Low MOQ heading. Explore uses
its own heading — the globe, 24px title and divider make it a different shape.

## Page backdrop (left/right edge blobs)

`components/layout/PageBackdrop.tsx` + `lib/page-backdrop.ts`, mounted once in
`layout.tsx` behind everything at `-z-10`.

**The geometry is real** — read off the Figma node tree (frame `1:2` children
`Ellipse 2/3/4/5`, ids `1:1481`-`1:1484` and `1:2017`) and recorded before the
API locked. They alternate down the two edges and bleed off both sides:

| node | x | y (page) | size |
|---|---|---|---|
| Ellipse 5 | 275 | 1412 | 927x651 |
| Ellipse 2 | 1057 | 1470 | 497x497 |
| Ellipse 3 | **-187** | 3050 | 497x497 |
| Ellipse 2b | 1057 | 3218 | 497x497 |
| Ellipse 4 | **-187** | 4370 | 497x497 |

These are absolute coordinates in the design's 1440x6783 page. The built page
measures ~6737 at desktop — within 1% — so they land correctly without scaling.

⚠️ **The colours, opacities and blur radii are NOT verified.** The REST API was
rate-limited, so those nodes' fills could not be read; the values in
`pageBlobs` come from the palette the rest of the design uses and are
placeholders. Positions are the part to trust.

The stage is centred with `left-1/2 -translate-x-1/2`, **not** `mx-auto` —
auto margins cannot centre a child wider than its container, so below 1440 the
1440 stage collapsed to left-aligned and the right-hand blobs left the page.

## Page background — partly unresolved

`--background: #fbfafb` is in place and is what the live build ships. But Figma
appears to carry **more** than a flat fill, and it is not reproduced yet:

- The blurred-ellipse group `1:1486` *"gradient color"* (the one the footer sky
  is baked from) is **`height 8502` against a 6783-tall page** — it spans the
  whole document, not just the footer. Only its footer slice exists today, as
  `public/footer/sky.png`.
- `Group 512` / `Group 514` are a **repeating 120x120 rectangle grid** — twelve
  columns across 1440, rows every ~120px, tiled in 479x911 blocks. Their
  **fills were never captured**, so whether this is a hairline graph-paper
  pattern, a tint, or invisible is unknown. It does not show in the footer
  render, so it is subtle at most.
- Page-level glow ellipses (`Ellipse 2/3/4/5`, ~497x497) sit behind sections.

Finishing this needs the node tree again — see the note on re-pulling below.
**Do not guess these values.** Guessing is exactly what produced the wrong
footer gradient, and the real answer (ten blurred ellipses, a yellow `#eefc72`
blob, 77% group opacity) was nothing like the plausible guess.

## Not built yet

- Home: Shop by Region. The promo banner grid is built but its tiles need
  re-exporting — see the warning above.
- The other pages: Product Detail, Login, Cart/Checkout, Ship-for-me, RFQ,
  Cost Calculator. Categories now has its **mega-menu** (below) but no
  standalone `/categories` route.
- **Backend.** Everything is static data in `src/lib/*.ts`. Those files are the
  single swap point when the API lands.

- **Cost Calculator** (`app/cost-calculator/page.tsx`, `components/calculator/`,
  data in `lib/calculator.ts`) — two-column: hero text + route-leg chips +
  transit stats on the left, a calculator card on the right, four assurance
  tiles below spanning both. Built from a screenshot (Figma still rate-limited
  at the time — see the gotcha on starter-tier tokens), so structure and copy
  are solid but **exact px/colour values are estimates**.
  The four leg chips (`International flight / Sea & air / China warehouse /
  Doorstep delivery`) render as **plain spans, not buttons** — only the first
  is ever emphasised in the design and none of them toggle anything, so making
  them look interactive would be inventing behaviour that isn't there.
  The **assurance tiles use flat line icons**, not the design's illustrated 3D
  art — none of the four existed in the cached image-fill map, so there was
  nothing to export. Swap in real art if it turns up.
  Two fields are genuinely ambiguous from the screenshot: **"Product category"
  renders as a plain text input** (no chevron is visible in the design, though
  a select would be the more correct control), and **the Weight field is a
  single input** — the design appears to show "2.5" twice in that row and it
  was not clear what the second occurrence means, so nothing was invented for
  it. Worth confirming both against Figma once it's reachable.

## Open questions for the client

- **The "Cost Calculator is identical to RFQ" note was stale**, same as the
  matching one about Categories turned out to be — the client has since
  supplied a real, distinct Cost Calculator design (see below).
- The Categories mega-menu's **supplier counts** (`6.2k`, `5.1k`, …) were read
  off a 823px screenshot and are the least trustworthy thing in it —
  **verify the digits**, same as the VisitLanes figures.
- Only **Apparel & Textiles** is drawn out in that design. The other eight
  categories' subcategory lists are authored placeholders — they need real
  taxonomy from the client or the catalogue API.
- The 5 second-page category icons exist only in Figma, never deployed to the
  live site.
- **Low MOQ Products only has 3 products designed**, but its pagination bar is
  drawn with 4 segments — implying ~12. The rail is built to page 3 at a time
  and hides the bar while there is only one page; it needs the other 9 products
  (or a decision to repeat) before the carousel does anything.

## Responsive

The home page was built desktop-first at the 1200 container and later made to
hold together down to phone widths. Swept every 20px from **320 to 1600** —
no sideways scroll anywhere from **360 up**. (320 still scrolls **3px**; the
cause is not any single element — nothing unclipped exceeds the viewport and
`html { overflow-x: clip }` is applied and computed — so it looks like a
sub-pixel rounding artifact. 3px on a viewport size essentially no current
phone uses, so it was left rather than chased further.)

What carries the layout:

- **Header** — below `sm` the search moves to **its own row** (inline, the logo
  and icon row leave it ~120px). Logo steps 112 → 140 → 176; avatar 44 → 64.
  The icon row sheds buttons as it narrows — **cart and account always
  survive**. `Categories` appears at `md`, `Location` at `lg`.
- **Top utility bar** — appears at `sm`, but all three contacts plus the
  language pill do not fit until `md`, so the email hides below that. This
  overflowed at *exactly* 640, which is the sort of thing only a dense sweep
  catches — test breakpoint boundaries, not just the common widths.
- **Low MOQ** — the paged rail drops to `grid-cols-2` below `sm`; at three
  columns the card titles wrapped to three lines on a phone.
- **Popular Categories** — the rail is a **flat scroller, not fixed pages**.
  It used to group cards into `w-full` pages of five; because the cards are
  `shrink-0`, as soon as five stopped fitting they spilled out of their own
  page and rendered *on top of* the next page's cards. `pageCount` is now
  measured from `scrollWidth / clientWidth` via a `ResizeObserver` instead of
  being derived from `CATEGORIES_PER_PAGE`, so the scrollbar thumb stays honest
  at every width.
- **Categories menu on phones** — the `Categories` pill is hidden below `md`,
  so a **hamburger** (same `aria-haspopup="dialog"` toggle) carries it there.
  The panel itself collapses: three columns → two at `md` → one below that,
  with the category rail becoming a horizontal chip strip, the promo column
  dropped entirely, and the sheet capped at `calc(100dvh-140px)` and scrolled.
- Hero already scales its 1200x554 stage; Promo, Visit lanes, New Collection,
  Top Picks and Explore already carried column breakpoints.

⚠️ `html { overflow-x: clip }` in `globals.css` is deliberate and load-bearing:
`clip` **not** `hidden`, because `hidden` makes it a scroll container and kills
the sticky header. It also has to sit on `html` — body's overflow only
propagates to the viewport while html is `visible`.

## Gotchas worth remembering

- **A `motion` element stuck at `opacity: 0` is usually the dev server, not your
  code.** After a run of rapid edits, Fast Refresh churn can leave `whileInView`
  (and even plain `animate`) components pinned at their `initial` state — the
  markup is correct, no console error, and a longer wait does not help. Swapping
  `motion.div` for a plain `div` renders fine, which makes it look like a real
  bug. **Verify against `npm run build && npx next start` before debugging.** It
  cost an hour here; production was correct the whole time.
- **`useId()` cannot be used raw inside `url(#…)`.** React returns ids
  containing `:` / `«»`, which are invalid CSS selectors, so an SVG gradient or
  mask referencing one silently falls back to black. Strip punctuation:
  `` `star${useId().replace(/[^a-zA-Z0-9]/g, "")}` ``. Hit by the partial star
  in `TopPickCard`.
- **`sips --cropOffset` silently no-ops** when the left offset is `0` and the
  top offset is not — it returns the source image at full size, or centre-crops.
  Anything that must be cut precisely is safer done with an explicit cropper
  (there is a zlib-based one in the scratchpad history) or verified afterwards.
- **Figma's image-fill map is the asset backdoor.** `/v1/files/{key}/images`
  returns every `imageRef` in the file mapped to a signed S3 URL that keeps
  working for ~2 weeks and does **not** count against the API quota. Cache that
  JSON early. When the API is locked, the way to find an asset is to sweep the
  refs by `Content-Length`, download the plausible ones, and tile them into a
  contact sheet to identify by eye — that is how the footer's payment marks,
  airplane, buoys and ship were recovered. Bare `imageRef` names are 40 chars;
  truncated prefixes will not match the files on disk.

- **Figma blend modes lie about colour — check the parent group, not just the
  fill.** The offer banner's copy is painted pure `#ffffff` with `NORMAL` fill
  blend, yet renders dark navy: its *wrapping group* carries
  `blendMode: DIFFERENCE`. The navy variant has no such wrapper, so its copy
  really is white. Reproduced with `mix-blend-mode: difference` rather than
  hardcoded colours, so it stays correct if a gradient changes. Each slide sets
  `isolation: isolate`, otherwise the blend reaches past its own slide during
  the crossfade. This is the second instance of the pattern — see also the
  LINEAR_DODGE header pill and the Low MOQ card.

- **A Figma `IMAGE` fill with `scaleMode: STRETCH` is a *crop*, and its
  `imageTransform` is not optional.** The matrix maps normalised node space to
  normalised image space, so row 0 gives the visible `u` range and row 1 the
  visible `v` range. Ignore it and you draw the whole source: the footer ship
  came out nearly twice its proper height that way, because its node shows only
  `v` 0.296–0.821 of the file. Easiest fix is to bake the window into the asset
  (`fig/recrop.py`) and then draw it 1:1 — no CSS crop gymnastics.

- **Figma `LAYER_BLUR` radius maps to a gaussian sigma of about radius/2.**
  Swept 0.4–1.1 against the rendered design; 0.5 won and 0.4 was second, so
  radius 400 ≈ sigma 200. Worth re-checking rather than assuming, but do the
  check on *clean* areas — the first sweep was meaningless because the sample
  grid was sitting on the logo and the support card rather than on sky.

- **Trim transparent padding before `object-contain`.** Many of the payment
  marks are square sources (320x320) holding a wide logo, so contained into a
  66x22 box they collapsed to 22x22 and read as blank tiles. Cropping each to
  its alpha bounding box on export makes the natural aspect the *logo's* aspect,
  and the design's `w`/`h` boxes then size it correctly.

- **A guard that reads state while the setter uses the updater form is a trap.**
  When the categories menu still opened on hover, the click handler did
  `if (menuOpen && …) return; setMenuOpen(v => !v)`. `menuOpen` in that closure
  was still `false` from the render before `mouseenter` landed, so the guard
  never fired while `v` was already `true` — every click closed the menu, and
  it looked like it had never opened at all. Either read state through a ref or
  do the whole decision inside the updater; do not mix the two.

- **Turbopack serves stale CSS.** A `globals.css` edit silently did nothing until
  `.next` was deleted and the server restarted. If a style change appears to have
  no effect, suspect this before rewriting the CSS.

- **Check `next start` actually started.** A verification run showed a totally
  unstyled page and a comparison score twice as bad, which looked like the build
  had broken the CSS. The real cause was `EADDRINUSE` — the port was already
  held by another project's server, `next start` exited, and the screenshot was
  of a different app. Read the server log before believing a bad render.
- **PowerShell blocks `npm run dev`** ("running scripts is disabled"). `npm` in
  PowerShell resolves to `npm.ps1`. Fix: `Set-ExecutionPolicy -Scope CurrentUser
  RemoteSigned`, then open a new terminal. Workaround: `npm.cmd run dev`.
- **Grammarly causes a hydration error** by stamping `data-gr-*` attributes onto
  `<body>` pre-hydration. Handled with `suppressHydrationWarning` on `<body>`;
  A/B tested to confirm. Headless browser checks will never reproduce
  extension-caused issues.
- `useCartTotals` needs `useShallow` — without it Zustand v5 returns a fresh
  object each render and throws "Maximum update depth exceeded".
- `public/HERO-SECTION` is ~35MB of uncompressed source PNGs (some 3600px wide).
  Next.js optimises on delivery, but compress before production.
